Intelligent, Security-Conscious, and Designed for Effortless Productivity
Because With Rullst, We Rule! Rullst is a modular Rust framework suite built on
Tokio, Axum, Tower, and SQLx for full-stack applications and the product
workflows around them. Version 13 is in prerelease: 13.0.0-alpha.4 is
published and is the 13.0 feature freeze. The v12 line reached end of life on
16 October 2026. Use the capability and release documents to distinguish
implemented behavior from roadmap work and application-specific
responsibilities. This book is built from the main branch, which can be
ahead of the latest prerelease; the v13 Preview chapters describe features
that are new in v13.
Welcome to the Rullst documentation. Build a small application, understand the code behind it, then explore the tools that fit your next idea.
New here? Start with the guided learning path →
Choose a generated application, a hand-written first route or a JSON API. Each path tells you what to run, what to expect and where to go next.
Documentation Hub
- ✨ Why Rullst?
- 📖 Getting Started & Starters
- 🤖 Rullst AI: Developing with Autonomous Agents
- 📊 Rullst Studio: Real-Time Monitoring & Radar
- ⚙️ Rullst Nexus: Explicit Admin CMS
- 💳 Rullst Capital: SaaS Billing & Revenue Dashboard
- 🧠 Integrating AI into Rullst
- 🍳 Rullst Cookbook & Tutorials
- 📜 Framework Spec
- 🗺️ Blueprints Roadmap
- 💻 CLI Reference
- 🛡️ Audit Report
- 📦 View on Crates.io
For AI assistants
The site publishes llms.txt, an
index of every page of this book in reading order, and
llms-full.txt, the full
Markdown of those pages in one file. Both are generated from this book’s
sources each time it is published, so they describe the same main branch.
Learn the foundations, then explore
- 🚀 Measured performance: Criterion suites and CI track regressions; application latency must be measured on the target workload.
- 🧩 Server-rendered UI:
html!and HTMX-oriented scaffolds are the audited default. LiveView and Wasm Island primitives exist, while turnkey Leptos/Dioxus adapters remain roadmap work. - 📖 Interactive Scalar Docs (
/docs):cargo rullst make:scalarscaffolds an OpenAPI viewer for your reviewed specification. - ☸️ Kubernetes manifests:
cargo rullst make:k8sscaffolds manifests that probe/healthand/ready. - 📡 Rullst Radar & Prometheus: Process/Tokio observations and a Prometheus text exporter, with unavailable probes reported explicitly.
- 🛡️ Local AI option: Use an Ollama endpoint when prompts should stay on infrastructure you control; network isolation remains a deployment duty.
- 📦 Batteries Included: ORM (rullst-orm), Auth, Revenue Dashboard, Jobs, Mail, Scheduler, Cache, Security.
Your first idea, running in Rust
You do not need to understand every Rullst crate before writing your first application. Start small, make one thing work, and learn what each layer does as your product needs it.
This guide targets the v13 prerelease, 13.0.0-alpha.4. The v12 line reached end of life on 16 October 2026 and a new 12.x project no longer resolves its dependencies, so install the CLI and pin the framework with the exact version, as Getting Started shows: Cargo never selects a prerelease on its own. Upgrading an existing application? Follow the v13 migration guide. This book is built from
main, where v13 is in development.
Pick your starting line
| What you want to do | Start here | Your first visible result |
|---|---|---|
| “Take me from an empty folder to a complete app.” | Zero to a complete app, about an hour | Sign-in, owner-only CRUD pages, tests, a security report and a production build |
| “Give me an application I can explore.” | CLI installation and starters | Generated Rust, a local web page and a development loop |
| “Show me how the framework actually works.” | Zero to Hello Rullst | One complete typed route you write yourself |
| “I need a backend for another client.” | Your first JSON REST API | A real HTTP response you can inspect with curl |
For the simplest first experiment, choose Blank + SQLite, or Blank with
--no-database if you do not need persistence yet. The larger SaaS starter,
and the LMS, ERP, Portfolio and Blog examples, have more moving parts
and longer first builds. You can explore them after your toolchain and local
workflow are working.
A small loop that teaches a lot
- Run it. Reach the local page or JSON endpoint before adding features.
- Find the code. Open
src/main.rs, then the generated controllers, models and pages that exist in your chosen starter. - Make one visible change. Edit a heading or response field. With
cargo rullst dev, watch the rebuild and process restart; with plaincargo run, stop and restart it yourself. - Break something safely. Introduce a small syntax error in your local experiment. Read the compiler diagnostic, correct it and save again. The supervisor keeps the old application running when compilation fails.
- Verify the result. Reload the page or repeat the request. A successful build is only the first check; the response should do what you intended.
See exact restart and state boundaries. Do not use production databases or credentials for this exercise.
Build your understanding, one layer at a time
Looking for a type, a feature or a connected REST learning path? Use the API reference index to move between the guides and the versioned Rust documentation.
| Next question | Guide |
|---|---|
| Where do generated files go? | CLI generators |
| How do I save and read data? | Active Record CRUD and migrations |
| How does the page become interactive? | HTML and HTMX |
| How do requests reach my handlers? | Routes and middleware |
| Who is allowed to access a record? | Ownership, RBAC and IDOR |
| How do I update the framework later? | Assisted upgrades |
| Which crates are stable, and which are experimental? | Maturity tiers |
You can use an AI assistant while learning. Ask it to explain the generated files, point to the exact APIs and show a failing test before a bug fix. Treat its output as a proposed change, not as proof. Rullst’s architecture specification is the common reference for both of you.
When the first run does not work
| What you see | Check first |
|---|---|
cargo rullst is unknown | Install the matching CLI, then reopen your terminal if its binary directory is not on PATH. |
| The first build is taking a long time | Source builds compile Rust dependencies. Keep the output visible; elapsed time is not a reliable failure signal. |
| A database connection fails | Confirm the selected backend and local .env values. PostgreSQL/MySQL/MariaDB need a running service; SQLite does not need a separate server. |
| The app cannot bind its port | Stop the conflicting process you own, or configure another port and restart the development command. |
| Optional persistence feels confusing | Selecting none is valid. Add only the specialized stores your application actually needs. |
| A command differs from a screenshot | Use the installed CLI’s --help and the matching source documentation. Screenshots are recorded examples. |
If you are still stuck, share the command, operating system, Rust version, framework commit and a minimal reproduction in an issue or on Discord. Remove secrets and personal data first. Send security vulnerabilities privately using the security policy.
Before this becomes a real product
A generated app is a foundation, not a deployment approval. Review authentication, ownership, secrets, database backups and migrations, external provider setup and release status. Start with the security architecture, choose features using the capability status, check each crate’s maturity tier (Core, Extension or Experimental), and measure your own workload.
Ready? Create your first application →
Getting Started
Welcome to the Rullst Getting Started guide!
Your goal: install the matching v13 CLI, generate a small application, open it locally and make your first change. Prefer to write the first route yourself? Use Zero to Hello Rullst.
Rullst is a strictly typed Rust framework suite for full-stack applications, designed around explicit APIs, measurable performance, and defense-in-depth defaults.
1. Installation
First, ensure you have Rust installed. The official and recommended way is to visit rustup.rs.
For macOS and Linux:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
For Windows:
Download and run rustup-init.exe from the website.
Next, install the Rullst CLI from the same release train as the
framework. This book is built from main, where v13 is in development. The
v12 line reached end of life on 16 October 2026 (12.3.0 was its last release,
and a new 12.x project no longer resolves its dependencies after crates.io
yanked the zip releases its Studio needs), so install the v13 prerelease. Name its exact version: Cargo never selects a
prerelease on its own, so neither a plain cargo install cargo-rullst nor
^13 installs it:
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
Pin the framework the same way in Cargo.toml ("=13.0.0-alpha.4"); the
v13 migration guide explains why and what changed.
Source reviewers who intentionally need checkout-local changes can instead
clone this repository and install the CLI from that exact checkout. On main
this installs the v13 development CLI:
git clone --branch main https://github.com/Rullst/Rullst.git
cd Rullst
cargo install --locked --path cargo-rullst
cd ..
When installed from source, the CLI reuses the exact checkout from which it was compiled, even when project creation is invoked from another directory, provided that checkout has not been moved or deleted. Generated manifests therefore contain absolute path dependencies and are not portable. Running from the repository root remains an explicit fallback. Prefer the published prerelease and its matching registry packages for ordinary applications.
Cargo installs the cargo-rullst and rullst executables into
$CARGO_HOME/bin (~/.cargo/bin by default). To install elsewhere, set
CARGO_INSTALL_ROOT or pass --root <dir>. To remove the CLI, run:
cargo uninstall cargo-rullst
Add the same --root <dir> if you installed with one.
2. Creating Your First Project
Run the create wizard with the name of your application (or run
cargo rullst alone and choose Create New Project on its home screen):
cargo rullst new my_app
The wizard asks its questions in a terminal and writes nothing until you confirm. Esc goes back one question.
- Starter: Blank, API or SaaS, each with a one-line description and a preview of the files it generates (see Rullst Starters).
- Database: the primary database. SQLite is the simplest local first run; PostgreSQL, MySQL and MariaDB need their database service running.
- Features: optional features such as AI, Redis or a storage add-on. Selecting none is valid; a storage add-on is not another primary database.
- Review: the files and the commands it will run. Choose Create the project, Back to change an answer, or Cancel to exit without creating anything.
For a database-backed app, the CLI then compiles the new application once and
runs its initial migration, and prints three numbered next steps: cd my_app,
cargo rullst dev and the local URL of the generated welcome page, by default
http://127.0.0.1:3000:
cd my_app
cargo rullst dev
Open the local URL reported by the command. Find the generated page code,
change a heading and save. Confirm that the changed page appears after the
successful rebuild/restart. Stop development with Ctrl+C before moving or
removing the project directory.
cargo rullst new my_app --default --dry-run prints the plan, its file tree
and the commands without creating anything, and cargo rullst tour walks
through the main commands without changing a project. Without a terminal
(in CI or a script), new needs --default; flags such as --blueprint and
--database answer their questions. See the
CLI reference.
Prefer a small deterministic starter without navigating the wizard?
cargo rullst new first_app --default --blueprint blank --database sqlite \
--skip-initial-migration
cd first_app
cargo rullst dev
The first build compiles every dependency and can take several minutes
(about two and a half minutes for the Blank starter on the machine measured
in Build times, which also has tips). --skip-initial-migration
defers the generator’s bootstrap work; the development command runs the
initial migration before starting a generated database-backed app. Never point
this learning project at a production database.
Tip
The
cargo rullst devcommand compiles the project and starts the local server. Saving changes triggers a real rebuild and supervised process restart; a failed build leaves the previous application serving. The same-origin browser client refreshes after the new process responds with its generation marker. In-memory state resets. No hot-reload scaffold option is required. See the CLI reference.The CLI deliberately generates one audited application profile: Active Record with server-rendered
html!views and HTMX enhancement. The framework still exposes lower-level repository and client-runtime foundations for application-owned integration. See the Architecture Choices Guide.
3. Rullst Starters
cargo rullst new generates one of three starters. The wizard asks which one
(or pass --blueprint blank, api or saas); each uses the Rullst color
scheme and server-rendered HTML/HTMX patterns, and allocation and latency
depend on the generated page and runtime.
Blank
Use Case: Custom, from-scratch development.
This is the minimal template powered by server-rendered html! views and HTMX
without a project-local JavaScript bundle. It includes a simple reactive counter
to demonstrate server-driven communication. Other frontend foundations remain
available as application APIs, but are not advertised as equivalent generated
starters.
API
Use Case: Headless JSON services.
The Blank starter without HTML (--blueprint api, or --api): JSON routes, an
example write route protected by a bearer API_TOKEN written to .env, and
the same production security baseline. It can run without a database
(--no-database) or with Turso as its primary.
SaaS
Use Case: Subscription-based products and billing. An opinionated SaaS starting point, pre-wired with:
- User authentication (login, signup, session management, sign out
everywhere, password change and password reset by email with links built
from
RULLST_PUBLIC_ORIGIN). - Stripe pricing panels and subscription checkout views.
- Secure user dashboard and a Nexus admin for signed-in administrators.
Larger example applications
Up to 13.0.0-alpha.3 the CLI also generated LMS, Blog, Portfolio and ERP
starters. Since 13.0.0-alpha.4 they are complete applications in the repository’s
examples/ directory — examples/lms (courses, lessons, an
accessible player, enrollment and owner-bound progress), examples/erp
(products, stock and orders), examples/portfolio (a developer showcase edited
in Nexus) and the examples/blog showcase — tested in the workspace. Copy one
to start a bigger application; cargo rullst new --blueprint lms (or erp,
portfolio, blog) creates nothing and names the example instead.
Tip
Starter evolution: Starters are continuously checked by generator smoke tests. Treat generated code as an application starting point: inspect its configuration and rerun
cargo checkand security tests after customization.
Local Studio and Nexus access
The SaaS starter (and the LMS, ERP, Portfolio and Blog examples) expose visible buttons for both control surfaces during local development:
- Nexus is mounted at
/nexusand accepts only a verified loopback peer in a debug build, so the first local click needs no placeholder password. - Studio starts as a separate debug-only service at
http://127.0.0.1:5555.
This convenience cannot be enabled in a release binary through RULLST_ENV or
the legacy APP_ENV alias. A
release build does not start the generated Studio task, and Nexus needs a
real sign-in:
- SaaS (and the LMS example) admit the user signed in to the application whose
users.is_admincolumn is true, checked on every request; no extra credentials. Grant the first administrator with your database client (UPDATE users SET is_admin = TRUE WHERE email = '[email protected]';); that administrator can grant others from the Users model in Nexus. See using the application’s sign-in. - The Portfolio and ERP examples require unique
NEXUS_ADMIN_USERNAMEandNEXUS_ADMIN_PASSWORDvalues (Basic Auth behind verified TLS). Without them the application starts without/nexus(the ERP without its back office) and prints one line naming the missing variable; see serving the application without Nexus. The blog example needs the same variables in a release build and does not start without them.
Put production Nexus behind a verified TLS boundary; do not expose Studio publicly. Studio has no production mode: it runs only in debug builds and on loopback.
4. Database configuration and specialized stores
The wizard deliberately makes two separate decisions:
- Choose the primary SQLx Active Record backend: SQLite,
PostgreSQL, MySQL, or MariaDB. MariaDB uses the MySQL wire
protocol but has its own executable container contract. The Blank starter
(web page or JSON API) can instead select Turso/libSQL as its primary
database, through a separate adapter that has no Nexus, Studio database
pages,
Schemamigrations,artisan!or SQLx query builder. - Optionally add side stores: Turso/libSQL edge SQL, MongoDB documents, DuckDB analytics, SurrealDB documents/graph reads, Qdrant vectors or Redis structures. They use their own APIs and do not replace the primary pool.
Database Support lists what each backend works with; read it before you choose.
After generation, the default flow compiles the new application once and runs
its initial migration. A clean first build can take several minutes for the
larger SaaS starter. Use --skip-initial-migration when the network database is not yet
configured, then run cargo rullst db:migrate from the generated project.
cargo rullst dev applies pending migrations when it starts, but a deployed
server never does: run the binary’s db:migrate command on each release, as
described in Before the first deploy.
The non-interactive flags mirror the second step:
cargo rullst new edge_app --default --database mariadb --turso --mongodb \
--skip-initial-migration
Automation can pin the supported starter, database and optional runtime
capabilities. The application architecture is intentionally fixed to Active
Record plus server-rendered html!/HTMX:
cargo rullst new subscriptions --default --blueprint saas \
--database postgres --ai \
--skip-initial-migration
For a blank application with no primary relational database, use the explicit
--no-database flag. It cannot be combined with --database.
Generated SQLx profiles disable the default features of Rullst’s umbrella and
rullst-orm dependencies and select exactly one strict relational backend, so
a chosen PostgreSQL/MySQL/MariaDB profile is not accidentally compiled through
an implicit SQLite default.
To create a Turso-primary API using the current bounded blank starter:
cargo rullst new edge_api --default --api --database turso \
--skip-initial-migration
cd edge_api
cargo rullst db:migrate
The generated .env selects a persistent, real-SQL offline fallback and does
not invent a SQLx DATABASE_URL:
TURSO_DATABASE_URL=mock_local
TURSO_AUTH_TOKEN=
TURSO_OFFLINE_PATH=turso-development.db
To use Turso Cloud, put the token in its own variable rather than in the URL:
turso db create my-app-db
turso db tokens create my-app-db
TURSO_DATABASE_URL=libsql://my-app-db-username.turso.io
TURSO_AUTH_TOKEN=replace-with-a-secret-token
The familiar derive explicitly selects the Turso backend:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "users", backend = "turso")]
struct User {
id: i64,
name: String,
}
}
User::all(), find, save, create, delete, count, filtering,
ordering and pagination then execute through the primary TursoOrm store.
make:model --migration, make:migration, db:migrate, db:status, and
db:rollback preserve this backend. The current first-class scaffold contract
is deliberately limited to the Blank and API starters; the SQLx-specific SaaS
starter rejects --database turso until ported.
Transparent embedded-replica synchronization is not claimed.
TursoStore remains available for prepared SQL, bounded result
materialization, transactions and checksummed migrations in either primary or
additive configurations.
See Polyglot Persistence for complete setup,
security constraints and examples for every optional store.
Build Times
Most of the time spent waiting on a Rullst application is cargo build or
cargo rullst dev rebuilding it after an edit. This page states what a
generated project does to keep those builds short, the measured effect on the
three starters, and other things you can try.
What cargo rullst new writes
Since 13.0.0-alpha.4 every generated Cargo.toml has a development profile
next to the release profile:
[profile.dev]
debug = "line-tables-only"
[profile.dev.package."*"]
debug = false
- Your package keeps line tables. Panic messages and the frames of your
own code in a
RUST_BACKTRACE=1backtrace still show the file and line (at ./src/main.rs:2:11). Frames inside dependencies show the function name without a file and line. - Dependencies are built without debug information. Their debug
information was about half of a debug binary and 41 % of
target/: the API starter’s binary went from 83.7 MB to 41.5 MB and itstarget/directory from 2.47 GB to 1.45 GB. - Nothing else changes. Optimisation level, incremental compilation,
overflow checks and debug assertions keep Cargo’s development defaults.
cargo testuses the same settings (Cargo’stestprofile inheritsdev), and release builds are unchanged. - For a debugger that shows local variables, set
debug = truein both sections (or delete them). Cargo then rebuilds everything once.
The SaaS starter also no longer declares rullst-connect: its code does not
use it, and cargo rullst make:oauth enables the facade’s oauth-* feature
instead. Its first build compiles 12 fewer packages.
The generated .cargo/config.toml is unchanged: on Linux it sets
-C split-debuginfo=unpacked and selects mold or lld when the generating
machine has one, and Git ignores it. With the profile above, split debug
information made no measurable difference on Linux (API starter: 146.0 s
with it and 149.1 s without for a cold build, 1.34 s and 1.40 s for a rebuild
after an edit). macOS already uses unpacked debug information by default and
was not measured.
Measurements
Measured on 29 October 2026 with the 13.0.0-alpha.4 development source. The numbers describe these starters on this one machine; they are not a prediction for other applications or hardware.
- Applications:
cargo rullst new <name> --default --skip-initial-migration, plus--apior--blueprint saas(SQLite;rullstfeaturesorm,strict-sqliteandstudio, andnexus,auth,mailandcapitalfor SaaS), with path dependencies on the framework source and the repository’sCargo.lock, built--offline. Before is the same source without the two changes above. - Toolchain: Rust 1.99.1,
x86_64-unknown-linux-gnu, linked by rustc’s bundledrust-lld(LLD 23.1.1; the default for this target since Rust 1.90). Neither mold nor lld was installed, so the generated.cargo/config.tomlselected no linker. - Machine: AMD Ryzen 5 7520U (8 logical CPUs), 3.4 GiB RAM, NVMe disk,
CARGO_BUILD_JOBS=2, a shared workstation; no other Cargo process ran during a measurement. - Commands: wall-clock time from
/usr/bin/timeofcargo build --offlineandcargo check --offline. Cold starts from an emptytarget/(dependencies already downloaded). Edit changes one string literal in a handler (src/main.rsfor Blank and API,src/controllers/auth_controller.rsfor SaaS) and rebuilds; touch only updates that file’s timestamp. Cold builds: mean of two runs; rebuilds: median of five.
| Starter | Cold cargo build | Rebuild after edit | Rebuild after touch | Cold cargo check | cargo check after touch | target/ after the build | Binary | Packages compiled |
|---|---|---|---|---|---|---|---|---|
| Blank, before | 175.4 s | 2.56 s | 1.72 s | 127.4 s | 0.83 s | 2.48 GB | 83.9 MB | 310 |
| Blank, generated | 147.4 s (−16 %) | 1.35 s (−47 %) | 1.09 s | 124.2 s | 0.62 s | 1.45 GB (−41 %) | 41.7 MB | 310 |
| API, before | 173.5 s | 2.32 s | 1.38 s | 128.1 s | 1.02 s | 2.47 GB | 83.7 MB | 310 |
| API, generated | 147.5 s (−15 %) | 1.34 s (−42 %) | 1.22 s | 123.6 s | 0.50 s | 1.45 GB (−41 %) | 41.5 MB | 310 |
| SaaS, before | 202.0 s | 4.29 s | 3.11 s | 137.9 s | 1.28 s | 2.99 GB | 113.7 MB | 349 |
| SaaS, generated | 163.2 s (−19 %) | 2.14 s (−50 %) | 2.19 s | 132.6 s | 1.05 s | 1.74 GB (−42 %) | 65.7 MB | 337 |
Cold checks are one run each; checks after touch are the median of three.
cargo check generates no code and links nothing, so the profile changes it
little; its target/ still went from 1.11 to 0.81 GB (SaaS: 1.26 to
0.92 GB). Removing rullst-connect alone, with full debug information, took
the cold SaaS build from 203.7 s to 194.1 s and its cold check from 137.9 s
to 135.2 s (one run each).
Other development settings, measured the same way on the API starter (one run each):
[profile.dev] | Cold cargo build | Rebuild after edit | Binary | target/ |
|---|---|---|---|---|
| Cargo’s default (full debug information), as generated before | 173.9 s | 2.61 s | 83.7 MB | 2.47 GB |
The same, without the generated .cargo/config.toml (as on a fresh clone or in CI) | 177.0 s | 5.52 s | 269.0 MB | 2.76 GB |
debug = "line-tables-only" for every package | 156.5 s | 1.78 s | 71.3 MB | 1.93 GB |
| Generated: line tables for the application, none for dependencies | 146.0 s | 1.34 s | 41.5 MB | 1.45 GB |
debug = false for every package | 146.8 s | 1.34 s | 35.2 MB | 1.44 GB |
Line tables for your own package cost nothing measurable, while those of the
dependencies cost about ten seconds of every cold build. Without split debug
information (no .cargo/config.toml), the full-debug link was also the
largest process of the build: 1.2 GiB, against 0.7 to 0.8 GiB for every
other setting.
Where a cold build spends its time
cargo build --timings writes target/cargo-timings/cargo-timing.html with
the time of every compiled unit. In the generated API and SaaS builds above
the longest single unit was the build script of aws-lc-sys, the
cryptography library behind the HTTPS client, at about 52 s; then
rullst-core (about 13 s), jsonschema (about 9 s) and the SQLite library
bundled by libsqlite3-sys (about 6 s). Until 13.0.0-alpha.3 some crates,
SQLite among them, were compiled twice: once for SQLx’s compile-time macros
and once for the application (see below). None of this depends on your code,
so it is paid again only after cargo clean, a toolchain update or a change
of features or profile.
Rullst keeps aws-lc-rs as the TLS crypto provider, for rustls’
post-quantum hybrid key exchange, so the aws-lc-sys build script stays in a
project’s first build. Since 13.0.0-alpha.4 SQLx shares it
(tls-rustls-aws-lc-rs, also in generated projects) instead of compiling
rustls’ ring backend as a second provider. Later builds reuse it, and the
dependency layer of the generated Dockerfile (the cargo-chef approach) caches
it in images; moving TLS to ring is not planned. ring remains a small
primitives library (HMAC, digests, randomness, signatures) that the crates
using those primitives depend on directly, because it also builds for wasm and
edge targets (see
cryptography providers).
SQLx macros and the Nexus assistant
Rullst never uses sqlx::query!, but until 13.0.0-alpha.3 rullst-orm and
rullst-core enabled SQLx’s macros feature for #[derive(FromRow)]. Every
application therefore also compiled sqlx-macros, sqlx-macros-core and, for
them, a build-host copy of SQLx, its SQLite driver and the bundled SQLite
library. Since 13.0.0-alpha.4 #[derive(Orm)] implements FromRow itself
and no Rullst crate enables SQLx’s macros (see the
migration guide);
the SaaS starter’s Nexus also leaves out its AI assistant and rullst-ai
unless the project is created with AI (nexus-ai). cargo tree -i sqlx-macros prints nothing for the Blank, API and SaaS starters.
Measured on the same machine on 29 October 2026 (Rust 1.99.0,
CARGO_BUILD_JOBS=2, cold cargo build --offline --timings, mean of two
runs, no other Cargo or rustc process during a run). Before is the
alpha.4 development source just before this change. The machine is shared,
so treat a few seconds as noise: the two SaaS runs after the change took
163.6 s and 144.8 s.
| Starter | Compiled units | Cold cargo build | target/ |
|---|---|---|---|
| Blank, before | 442 | 152.5 s | 1.4 GB |
| Blank, after | 380 | 130.1 s (−15 %) | 1.2 GB |
| SaaS, before | 469 | 170.5 s | 1.6 GB |
| SaaS, after | 406 | 154.2 s (−10 %) | 1.4 GB |
The 62 units no longer built are the two SQLx macro crates and the
build-host copies of SQLx, its SQLite driver and their dependencies, the
longest being a second run of the SQLite build script (about 12 s); the SaaS
starter also drops rullst-ai. An application that enables SQLx’s derive
or macros feature itself, for #[derive(sqlx::FromRow)] on its own structs
or sqlx::query!, compiles them again.
Tips
- Use
cargo checkwhile you edit. It skips code generation and linking: a check after an edit took 0.6 to 1.0 s in these projects (one run each), and a cold check was 23 to 31 seconds shorter than a cold build. rust-analyzer in your editor runs the same check when you save. - Keep
cargo rullst devrunning. It reusestarget/, so after the first build only your package is rebuilt on each save (the edit column above), then it restarts the server; restart time was not measured here. - Linker. On
x86_64-unknown-linux-gnu, Rust 1.90 and later already link with the bundledrust-lld, which all the measurements above used. On other hosts (for example aarch64 Linux) mold or lld may shorten links: on Linux,cargo rullst doctorsuggests one when rustc does not already use LLD and no linker is configured, and a project generated on a machine with one selects it in.cargo/config.toml. Not measured here. - sccache can share compiled dependencies between projects on the same
machine (
RUSTC_WRAPPER=sccache). Not measured here.
Existing applications
cargo rullst upgrade does not edit build profiles. To adopt the faster
development builds, copy the [profile.dev] and [profile.dev.package."*"]
sections above into your Cargo.toml. In an application created from the
SaaS starter before 13.0.0-alpha.4, you can remove the rullst-connect
dependency when no source file uses rullst_connect; cargo check tells you
whether one does. Upgrading to 13.0.0-alpha.4 removes SQLx’s macro crates from
the build once the models no longer derive FromRow and no manifest enables
SQLx’s macros or derive feature (cargo rullst upgrade locates both), and
rullst-ai once Nexus runs without nexus-ai.
Database Support
Read this page before you choose a database. It is the one place that says what each backend works with in Rullst 13.0.
Rullst integrates one family of databases fully: SQLite, PostgreSQL and
MySQL/MariaDB, through SQLx. #[derive(Orm)] models and their query builder,
Schema migrations, artisan! and the db:* commands, Server::with_db,
Nexus and Studio’s database pages all use that SQLx connection pool.
Turso/libSQL can be the primary database of the Blank starter, but through a
separate adapter (TursoOrm, TursoModel, TursoRepository,
TursoMigration) that does not plug into any of those parts. Integrating it is
roadmap work, not part of 13.0.
MongoDB, DuckDB, SurrealDB, Qdrant and Redis data structures are side stores: you add them next to the primary database and call them through their own APIs. They never replace the primary database, and there is no transaction across stores.
Capability table
| Backend | Primary database | #[derive(Orm)] and query builder | Schema migrations | artisan! and db:* | Server::with_db | Nexus | Studio | Transactions | cargo rullst new | Live CI test |
|---|---|---|---|---|---|---|---|---|---|---|
| SQLite | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Every starter; the default (--database sqlite) | No server (in-process): matrix_sqlite, Studio data_browser_mutation_sqlite, Nexus tenant_audit |
| PostgreSQL | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Every starter (--database postgres) | matrix_postgres, Studio data_browser_mutation_postgres, Nexus sql_matrix_postgres |
| MySQL / MariaDB | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes, but DDL commits implicitly (note 4) | Every starter (--database mysql or --database mariadb) | matrix_mysql (MySQL 8.0), matrix_mariadb, Studio data_browser_mutation_mysql and _mariadb, Nexus sql_matrix_mysql (MySQL only) |
| Turso / libSQL | Blank starter only (note 1) | No: a separate model API (note 2) | No: SQL TursoMigrations (note 3) | No artisan!; the Blank starter answers db:migrate, db:rollback and db:status itself (note 3) | No | No | No (note 5) | Statement batches only (TursoStore::transaction) | Primary: Blank, web page or --api (--database turso). Side store: --turso with any starter | matrix_turso against libsql-server v0.24.32 |
| MongoDB | No | No (MongoDbStore) | No | No | No | No | No | No | Side store: --mongodb with any starter | matrix_mongodb |
| DuckDB | No | No (DuckDbStore) | No | No | No | No | No | No | Side store: --duckdb with any starter | None: an in-process engine, covered by unit tests |
| SurrealDB | No | No (SurrealDbStore) | No | No | No | No | No | No | Side store: --surrealdb with any starter | matrix_surrealdb (SurrealDB 3.2.4) |
| Qdrant | No | No (QdrantStore) | No | No | No | No | No | No | Side store: --qdrant with any starter | matrix_qdrant |
| Redis data | No | No (RedisDataStore) | No | No | No | No | No (note 6) | No: each command is atomic on its own | Side store: --redis with any starter (it also enables Redis queues and cache) | matrix_redis_data |
Notes:
cargo rullst newrefuses--database tursowith the SaaS starter (Blank and API accept it). SaaS uses SQLx models,Schemamigrations and Nexus, so it needs a SQLx database.#[derive(Orm)]with#[orm(backend = "turso")]generates theTursoModelcontract instead of SQLx code:all,find,save,create,delete,countandpaginate, plus filtered reads throughTursoOrm::repository::<Model>()?.query()(where_eq,order_by,limit,offset,get,first,count). It has no SQLx query builder, and the derive refuses relations, soft deletes, hooks, scopes, tenant columns, policies, auditing, Scout search and embeddings at compile time.- A Turso migration is a
TursoMigrationholding SQL statements, checksummed and reversible withwith_down; theSchemaandBlueprintbuilders never apply to Turso.cargo rullst make:migrationwrites one, andmake:migration:autorefuses Turso projects. The Blank starter’smain.rsanswersdb:migrate,db:rollbackanddb:status, anddb:seedreports that no seeders exist;cargo rullst db:*runs them throughcargo run. - MySQL and MariaDB commit each DDL statement implicitly, so a migration’s schema changes do not roll back with it; data statements are transactional on all three SQLx backends. See Tutorial 05.
- Studio still starts in a Turso Blank project, but its database, migration
and data browser pages read only a SQLx
DATABASE_URL, so they report that no database is configured. - Studio’s cache page shows the Core
Cachehanded to it (memory or Redis), notRedisDataStorestructures.
Where the live tests run
The live tests start the database in a container (Testcontainers) in the
Run provider matrices step of the Rust CI workflow’s Linux workspace
shard. matrix_postgres, matrix_mysql and matrix_sqlite also run in the
ORM strict-postgres, ORM strict-mysql and ORM strict-sqlite jobs.
The tests live in rullst-orm/tests/, rullst-studio/tests/ and
rullst-nexus/tests/, and each pins its container image. A live test proves
the adapter’s contract against that server version; it does not certify a
hosted service, backups, replication or failover.
Choosing
- Start with SQLite; move to PostgreSQL, MySQL or MariaDB for a shared server. Every starter and every framework feature works with these.
- Choose Turso as the primary database only for a Blank starter (a web page or
a JSON API) where you are ready to write SQL migrations and use the
TursoModelAPI without Nexus, Studio’s database pages,Schemaor the SQLx query builder. - Add a side store when the application needs documents, analytics, graph
reads, vectors or Redis structures next to its primary database. Enable only
the adapters you use: each one is its own Cargo feature (
orm-turso,orm-mongodb,orm-duckdb,orm-surrealdb,orm-qdrant,orm-redisonrullst), and DuckDB’s bundled C++ build makes a first build noticeably longer.
The APIs of Turso and the side stores are described in Polyglot Persistence; the SQLx ORM in rullst-orm; migrations in Tutorial 05; every Cargo feature in the feature matrix.
Find the API you need
There are two different meanings of API in this documentation:
- Rust API: the framework types, traits, functions and Cargo features your application imports. Use the versioned rustdoc references below for exact signatures and follow the book for application-level context.
- HTTP API: the endpoints your application exposes to a browser, mobile client or another service. Start with the REST learning path below; an OpenAPI document describes your endpoints, not the entire Rust framework.
The Rust reference links target the published 13.0.0-alpha.4 prerelease
packages; the v12 line reached end of life on 16 October 2026 (see the
release record). Versioned docs.rs pages become available after
registry publication and rustdoc builds. This book is built from main, which
can be ahead of that prerelease; for unreleased APIs, build local documentation
from the checkout. The index connects existing guides without certifying every public
symbol or v13 roadmap item. Check capability boundaries
before interpreting a provider or blueprint name as complete product support.
Rust reference: start with a task
| What you need | Application guide | Exact v13 prerelease Rust reference |
|---|---|---|
| Choose imports and start an application | First application | rullst facade |
| Route requests and compose middleware | Routing, Core | rullst-core |
| Query data and manage persistence | ORM, CRUD, backend boundaries | rullst-orm |
| Authenticate users and authorize access | Auth, ownership and roles | rullst-auth |
| Compose request defenses | Security, security architecture | rullst-security |
| Integrate social sign-in | Connect | rullst-connect |
| Integrate payments and signed webhooks | Capital, billing tutorial | rullst-capital |
| Call local or cloud language models | AI | rullst-ai |
| Send transactional email | rullst-mail | |
| Publish and consume messages | Messaging | rullst-messaging |
| Inspect the local app or compose an admin interface | Studio, Nexus | rullst-studio, rullst-nexus |
| Generate code or prepare a framework upgrade | CLI reference, assisted upgrades | cargo-rullst |
Search within the selected crate’s rustdoc page for an exact symbol. Read its
module context and feature requirements as well as its signature. A facade
re-export and a direct dependency can expose different features; enabling a
feature does not configure credentials, an external service or authorization.
For your application’s selected feature set, cargo doc --no-deps --open
builds its local documentation; remove --no-deps to include dependency APIs.
Run Cargo commands only in a trusted checkout: build scripts and procedural
macros can execute code during documentation builds too.
HTTP/REST: a connected learning path
- Return typed JSON. Run the endpoint and inspect its status, headers and body with curl.
- Compose routing and middleware. Understand which layer receives or rejects a request first.
- Validate input. This guide is form-oriented; its validation principles do not turn it into a complete JSON validation/error-handling tutorial. Define the JSON error contract explicitly.
- Persist records and manage migrations. Design bounded pagination and test failed writes as well as successful reads.
- Choose session or token authentication and enforce ownership/role authorization. Authentication alone must not grant access to another user’s records.
- Expose a reviewed OpenAPI specification. The viewer does not infer a complete specification or security policy from arbitrary handlers. Read its CSP, asset and missing-specification boundaries.
- Review deployment and security boundaries and the compatibility policy before exposing the app.
For your own API, test malformed and oversized input, authentication failure, cross-owner/tenant denial, missing records, bounded pagination and error responses that do not expose credentials or database details. Public demos and a compiling example are not substitutes for these application tests.
The v13 prerelease schema-first API candidate adds an explicit bounded OpenAPI profile, generated Rust codecs and a compiled TypeScript HTTP consumer. Its authenticated fixture proves the stated wire/ownership boundary; it does not close the broader CRUD/documentation programme below.
What still needs improvement
The existing chapters are useful but are not yet one complete, consistently tested REST product walkthrough. A coherent CRUD example with JSON validation, typed error responses, pagination, authorization negatives and matching OpenAPI is still needed. Per-symbol reference reviews also need explicit feature, error, concurrency and migration coverage. The API documentation plan tracks that work separately from this navigation index.
Why Rullst?
Rullst is an opinionated, Axum-based full-stack framework for teams that want the productivity of a coordinated application platform without hiding Rust’s types or the underlying ecosystem.
Its strongest distinction is not that every individual feature is unique. It is the combination of compile-time-oriented APIs, explicit security boundaries, first-party backend capabilities, offline development contracts, and one coordinated CLI and release train.
Version status: the Rullst v12 release line reached end of life on 16 October 2026; v13 is in prerelease (
13.0.0-alpha.4, installed with an exact version as Getting Started shows). This page lists implemented or explicitly bounded capabilities; it is not a production-readiness certificate. The framework specification and capability ledger hold the detailed contracts; where they and this summary disagree, they win.
The short answer
Choose Rullst when you want to build a backend-oriented Rust application with:
- an explicit Axum, Tokio, Tower, and SQLx foundation;
- compile-time-generated routes, HTML, and models, with compiler-visible diagnostics instead of runtime reflection;
- a coordinated ORM, authentication, security, jobs, mail, AI, billing, administration, observability, and CLI toolchain;
- secure, fail-closed defaults for privileged and production-facing surfaces;
- deterministic offline paths for supported external-provider integrations;
- server-rendered interfaces that do not require a project-local SPA bundle;
- standard escape hatches when a framework abstraction is not the right fit.
What makes the combination distinctive
1. AI-native means explicit and inspectable
Rullst is designed so that both humans and coding agents can reason about an application from its source. Common routes, models, HTML trees, policies, and scaffolds are represented by typed Rust or macro input rather than runtime class scanning or hidden reflection.
This does not make generated code automatically correct. It makes important structure available to the compiler, review tools, and the CLI. Macro diagnostics, compile-fail tests, generated-project gates, and the AST-based IDOR scanner turn that structure into evidence.
The AI layer follows the same rule. Its bounded RAG pipeline makes tenant context, retrieval, context limits, source metadata, and audit explicit in one typed operation. It rejects differently tagged or unsafe passages and refuses ungrounded generation when retrieval is empty. The included cosine retriever is clearly process-local; production datastore authorization and durability are not hidden behind an “automatic AI” claim.
Conversational memory follows that explicit model too: a static-dispatch tenant-bound contract has a bounded offline store and an opt-in SQL adapter for SQLite, PostgreSQL, MySQL, and MariaDB. Each successful turn commits the user and assistant messages atomically, while revision compare-and-swap rejects stale cross-process writers instead of silently scrambling history or automatically repeating a billable model request.
2. A broad platform that keeps its foundations visible
Rullst coordinates a large backend surface in one versioned workspace, but it does not replace its foundations with proprietary runtime primitives:
- applications can mount ordinary
axum::Routervalues; - middleware remains compatible with Tower composition;
- Tokio remains the asynchronous runtime;
- SQLx pools and raw parameterized queries remain available beside the ORM.
The Axum and SQLx interoperability guide documents the supported escape hatches. Framework conveniences can still require migration work, so Rullst describes this as reduced lock-in rather than “zero lock-in.”
3. Security boundaries are part of framework design
The framework treats authorization and privileged tooling as architectural inputs rather than deployment footnotes. Current bounded contracts include:
- fail-closed production environment and secret validation;
- CSRF, secure headers, request heuristics, DLP, abuse controls, and security telemetry primitives;
- route-scoped JSON Schema 2020-12 or OpenAPI 3.1-component enforcement with bounded offline compilation, local-only references and linear-time regexes;
- explainable aggregate threat assessment plus an opt-in authenticated, subject-bound, expiring and locally one-shot proof-of-work gate;
- Argon2id password hashing, expiring encrypted sessions, RBAC, model policies, and explicit owner-or-role guards;
- a typed OAuth/OIDC session transaction that keeps PKCE verifiers and OIDC nonces server-side, expires them after ten minutes, and consumes them before callback validation;
- a provider-generic automatic token coordinator that binds refreshes to the original user, redacts credentials, detects a bounded expiry window and prevents overlapping provider calls while waiters reuse a valid transition;
- an authenticated Nexus admin surface and a loopback/debug-constrained Studio;
- one bounded signed-payment-webhook verifier exposed through both Axum and opt-in Actix middleware adapters;
- versioned AES-256-GCM ORM field encryption with authenticated context;
- CI gates against
panic!,unwrap(), andexpect()in declared production targets.
These are defense-in-depth controls, not a claim that an application is secure without its own authorization model, deployment controls, reviews, and tests. The Sentinel is deliberately deterministic rather than marketed as autonomous AI: the host still owns aggregate collection, identity, accessible fallback, distributed replay state and enforcement. See the security architecture and v12 security claims.
4. Persistence depth without a fictional universal database API
The relational ORM provides generated Active Record and query APIs, transactions, migrations, relations, tenant scopes, policies, soft deletion, auditing, encrypted fields, typed pgvector helpers, structured telemetry, and an opt-in transactional outbox.
The primary relational matrix covers SQLite, PostgreSQL, MySQL, and MariaDB. Turso/libSQL has a separate typed primary profile. MongoDB, DuckDB, SurrealDB, Qdrant, Redis, and external search engines use capability-specific adapters so their different consistency and query models stay visible. MongoDB and SurrealDB share only a narrow identifier-preserving recovery boundary: a bounded encrypted snapshot can be rehearsed across both, while writer quiescence, schema provisioning, key custody and durable backup remain explicit application/operator work.
This is deliberate: Rullst prefers several honest, bounded APIs over one API that pretends documents, OLAP, graphs, vectors, key-value structures, and relational transactions have identical semantics. Read the polyglot persistence, transactional outbox, and Scout search guides.
The same boundary discipline now applies to messaging. rullst-messaging
defines one versioned envelope, bounded topic/group metadata, idempotent
publication, consumer groups, expiring acknowledgement leases, retry and
dead-letter semantics, with a deterministic concurrent in-memory contract. A
canonical bounded v1 envelope codec and allowlisted W3C trace-context boundary
make byte compatibility and correlation behavior executable. The codec still
does not open a remote connection or map broker-specific semantics. SQLite can
explicitly protect header values and payloads with row-bound AES-256-GCM and a
bounded rotation keyring while keeping visible metadata and operator duties
honest. The opt-in relational outbox relay also proves its unavoidable
publish-before-ACK crash window as an exact idempotent replay instead of calling
two systems one atomic transaction. That makes
offline tests and future adapter conformance explicit without
pretending the local broker is Kafka, RabbitMQ, NATS, Redis Streams, or a cloud
service. Applications still own topic authorization and idempotency of remote
side effects. See Bounded Brokered Messaging.
5. External services remain usable during offline development
Supported AI, OAuth, mail, billing, search, and persistence adapters expose
deterministic offline behavior when configured with their documented empty or
mock_* credentials. This lets generated projects, tests, examples, and local
sandboxes run without silently contacting a third party.
Mail scheduling follows the same bounded approach: SQLite and Redis persist a due time and never claim it early, while unsupported real direct transports reject a future message instead of delivering it immediately. Offline fixtures may retain that timestamp for assertions. Polling delay, at-least-once delivery, and provider acceptance remain visible operational boundaries.
The optional native AWS SES v2 path follows the provider’s real authentication model instead of disguising a bearer request as AWS support. It delegates regional SigV4 and credential-provider rotation to the official AWS SDK while retaining the same pre-flight, HTML/text, attachment/CID and RFC 8058 message contract. Its local signed-request fixture proves integration shape, not AWS account readiness, reputation or inbox delivery.
Mocks prove the local application contract, not the live provider contract. Provider-specific production support still requires the applicable live or protocol tests, credentials, policy, and operational evidence.
6. Framework upgrades are a product feature
cargo rullst upgrade --dry-run inventories a project and reports a versioned
migration plan. cargo rullst upgrade snapshots controlled files, updates the
coordinated Rullst dependency train, applies supported compiler fixes, runs a
Cargo check gate, and restores those files when the gate fails.
It intentionally refuses to invent database migrations, business rules, or security policy. The useful distinction is a bounded, recoverable upgrade transaction with human and machine-readable reports. See the assisted upgrade tutorial.
7. Server-first UI with optional escalation
The compile-time html! macro escapes supported dynamic text and attribute
values and returns Markup, so views nest without re-escaping, while
RawHtml makes an unescaped boundary for trusted outside HTML explicit. HTMX-oriented
server rendering is the default path for applications that do not need a
project-local SPA bundle.
LiveView and selected Wasm islands are optional strategies with separate maturity and operational requirements, and a template engine such as Tera is the application’s own choice. Rullst does not claim that every frontend mode is interchangeable or bundle-free.
The same server-rendered application becomes installable without a second
codebase: cargo rullst make:pwa adds a web app manifest, a service worker
with an offline page and an update prompt that work under the default
Content-Security-Policy, and never keeps no-store or private responses on
the device. The same site can be packaged for Google Play as a Trusted Web
Activity and for the Microsoft Store with PWABuilder; iOS users add it to the
home screen from Safari. Store review, signing keys and device testing remain
the application’s work; see the
PWA guide.
8. Local control surfaces use real application signals
Studio is a local developer control room with explicit unavailable states when a probe is not connected. Nexus generates an authenticated administration surface from registered model metadata and applies server-side field policy. Tenant-owned models can explicitly bind every built-in CRUD/search/batch path to a trusted Core tenant context, while an opt-in required audit row shares the mutation transaction and rolls it back on audit failure. The host still owns identity/membership and immutable external evidence; Nexus does not disguise a same-database mutable table as an append-only audit service. Studio’s relational browser can also perform deliberately narrow primitive row edits/deletions: the write surface exists only behind the verified local request capability, binds values, identifies one row by its inspected complete primary key and runs against four relational engines. It is not presented as a replacement for application tenant authorization or production administration. Studio flag toggles also invalidate already-warm database flag drivers in the same process without an unbounded key registry; cross-process invalidation remains an explicit application transport.
The queue monitor follows an explicit retention rule as well. SQLite removes successful payloads by default, while applications that need operational history can opt into a validated, atomically pruned limit and purge it from Studio. Rullst does not silently trade privacy for a more impressive dashboard.
The ORM emits secret-free structured spans for generated and raw query entrypoints, streams, transaction outcomes, and Rullst-owned pool acquisition timing. The host owns subscriber initialization, sampling, collector security, and retention. See the telemetry guide.
9. Scaffolding is treated as shipped code
The CLI can generate projects, routes, models, migrations, auth, mail, billing, deployment manifests, and other bounded application slices. Rullst tests representative generated projects and maintains structural matrices instead of assuming that a template is correct because its source file compiles inside the CLI crate.
The LMS example (examples/lms, the CLI’s LMS starter up to 13.0.0-alpha.3)
is more than a landing-page mock: it has a course
catalog with modules and lessons, sign-in and registration, enrollment,
owner-bound idempotent lesson progress and a Nexus admin. Its accessible lesson
presentation supports bounded video/audio metadata, requires a WebVTT captions
track for video and renders escaped transcripts. In v13 this small application
replaces the complete Academy scaffold that v12 generated (assessment, scores,
leaderboard, automation, notifications and school boundaries). It is a
foundation for a learning product, not a claim that Rullst generates pedagogy,
content, speech recognition or native-device behavior.
For example, make:billing --model Workspace now materializes distinct SQLx
and Turso-primary persistence profiles. Both are generated, linted, migrated
and exercised through subscription ownership and collision negatives; the
test does not turn provider sandbox validation or distributed reconciliation
into an automatic claim.
The billing facade follows the same bounded design: an explicit generic subscription handle delegates pause/cancel without erasing the provider type, while its grace-period value validates time bounds but leaves persistence, authorization and entitlement policy visible in application code. Its shared-quota boundary likewise derives one subject from trusted tenant state, reserves idempotently before creation and can commit the counter and a relational domain insert in the same caller-owned transaction. SQLite plus live PostgreSQL/MySQL/MariaDB contention contracts prove that concurrent workspace members stop at the configured limit; membership and tier reconciliation remain visible host responsibilities. Coupon and trial management follows the same evidence boundary: coupon IDs are bounded/redacted, Stripe binds the expanded applied discount, and 1–730-day relative trial updates have stable explicit-clock retries plus bound Stripe protocol fixtures. Providers without a reviewed live operation fail explicitly instead of presenting a local no-op as remote success.
The same CLI includes inspection, toolchain diagnostics, migration assistance, SBOM generation, and a bounded static route-access scanner. Generated output remains application code: review it, test it, and keep it under version control.
10. Ambition is separated from evidence
Rullst keeps ambitious ideas, but labels them as implemented, partial, experimental, offline mock, roadmap, or not recommended. Benchmarks are reported for their measured workload instead of becoming universal performance slogans. Hardware, provider, regulatory, and certification claims remain incomplete until the corresponding external evidence exists.
This distinction is essential for a broad framework. The capability ledger records the boundary. Dated comparative claims belong in reproducible, sourced research rather than in the active product documentation.
Where Rullst is a particularly good fit
Rullst is worth evaluating for:
- backend-heavy SaaS and multi-tenant applications;
- server-rendered products that still need jobs, realtime, mail, billing, and administration;
- Rust teams that prefer one coordinated release train over assembling every application subsystem independently;
- projects that need relational data plus explicit document, analytics, search, vector, graph, or key-value capabilities;
- organizations that value offline reproducibility, secure scaffolds, and machine-readable engineering evidence;
- teams using coding agents that benefit from explicit APIs, bounded files, strong types, and a normative specification.
When another choice may be better
Rullst may not be the best choice when:
- only a small HTTP router or middleware library is needed;
- a browser-component ecosystem is the primary product architecture;
- long production history, a very large plugin ecosystem, or commercial support matters more than Rust-native integration;
- the application requires a capability that the ledger still marks partial, experimental, or roadmap;
- the team does not want the maintenance surface of a batteries-included framework.
In those cases, using Axum directly, another Rust application framework, a frontend-centered framework, or a mature platform in another ecosystem can be the more responsible decision.
How to evaluate Rullst today
- Start with Zero to Hello Rullst or Zero to a complete app.
- Check the exact capability you need in the specification and capability ledger.
- Enable only the required Cargo features and inspect their dependency graph.
- Run the documented tests against your database, provider, proxy, and threat model.
- Pin an immutable release or commit for evaluation; do not deploy from a moving development branch.
This page describes the v13 prerelease line (13.0.0-alpha.4). It remains a
concise map of the strongest implemented ideas, not a substitute for the
detailed contracts or application-specific validation.
An AI Assistant’s Evaluation of Rullst 13.0.0-alpha.4
Who wrote this, and why you should read it critically. This page was written on 30 October 2026 by Claude Opus 5.5, an AI model made by Anthropic, working in Claude Code at the maintainer’s request. The maintainer asked for a candid assessment and did not edit its conclusions. Conflict of interest: the same assistant, with its sub-agents, wrote a large part of 13.0.0-alpha.4. That includes the lean CLI, the deployment test, the TLS provider change, the documentation audit and several CI gates. This is not an independent audit. It is based on the repository’s source, documentation, CI configuration and release history at the time of writing, not on interviews with users, production traffic or my own benchmarks. Treat it as one informed opinion, dated to this release. The Why Rullst? page is the maintainer’s own pitch.
The short version
As engineering, Rullst is unusually rigorous for a framework of its size and age, and unusually honest about its limits. As a product, it is still young: one maintainer, a fast-moving API and, as far as I can see, little validation outside the maintainer’s own applications. 13.0 is the release where that can start to change, if the 13.x API stays stable.
What stands out
-
Evidence over claims. The project runs 34 workflow definitions, among them:
- 42 libFuzzer targets in 11 fuzz crates;
- Miri and Kani on selected code, and thread and address sanitizers;
- property tests;
- a pull-request mutation gate for the CLI;
- an enforced 90% line-coverage floor;
- zero-panic and
unsafepolicies; - SBOMs with build-provenance attestations.
A release is admitted only at an exact commit that passed about two dozen required workflows. Few Rust web frameworks I know of, including far larger ones, do this much.
-
A culture of saying what is not done. Every release lists its known limitations. The capability ledger records abandoned ideas and why. Maturity tiers separate Core from Extension and Experimental. The documentation keeps saying “evidence for the stated scope, not a certification”. This is rarer than it should be, and it is the strongest reason to trust the project.
-
Batteries that are secure by default. CSRF, strict headers, a WAF with a development report-only mode, Argon2id, passkeys, revocable sessions and signed webhooks are wired in, not left as exercises. Removed features are removed loudly:
cargo rullst upgradehas a rule for each one. -
Built to be read by tools. It favours explicit APIs and compile-time macros over runtime reflection, and it has typed errors. Every generated project gets an
AGENTS.md, the site publishesllms.txt, and CLI data output follows one--jsoncontract. This helps humans as much as AI assistants. -
A tested path to production.
cargo rullst deployproduces a hardened Dockerfile,compose.yamland Caddyfile. CI builds and starts them with Docker and rootless Podman, and checks HTTPS, the container hardening, migrations and persistence. Writing that test found two real bugs that every SQLite deployment would have hit.
Where it is weaker
- Bus factor. Twenty packages, a CLI with dozens of generators and a large CI estate are maintained by one person. This is the biggest risk to anyone who adopts Rullst. It matters more than any bug.
- Breadth ahead of validation.
- Several integrations are tested only against offline mocks: the OAuth providers, Stripe beyond test mode, Bunny Stream, the mail providers.
- Five packages are Experimental: IoT, Privacy, Supervision, Media and Labs.
- The provider deployment guides were written from the providers’ documentation, not followed end to end on real accounts.
- Whatever is broad but unvalidated becomes maintenance load without becoming trust.
- Documentation drifted behind the code. Before this release, the
documentation audit corrected dozens of inaccuracies across the book. It also
found about ten first-hour breakages that no test caught; for example,
cargo rullst authrefused to run on every freshly generated starter. CI now builds and runs the generated deployment, and runs the commands of the first-hour pages (the README quickstart, getting started, the first tutorials and the REST API quickstart) as written. The lesson stands: in this project, code changes faster than its prose. - Churn. v12 reached end of life on 16 October 2026, and v13 changes a
lot: removed generators and providers, a leaner CLI,
FromRowgenerated by#[derive(Orm)], a single TLS crypto provider. Each change is reasoned and documented. But adopters judge a framework by how rarely it makes them migrate. The 13.x line needs to hold still. - First builds are still slow. A generated starter’s first build takes
about two to two and a half minutes on the measuring machine (see
build times). Most of it is cryptography (the
aws-lc-sysbuild script), SQLx and the framework itself. Later builds are much faster. - Process weight. A full release admission takes about 17 hours of fuzzing. Release fuzz evidence is now derived from each fuzz package’s build graph, so a change to the documentation, the CLI or the examples no longer reruns it, but a change to a book page that the facade’s doctests include still reruns the facade’s 12 targets. The rigor is real, but so is its cost for a single maintainer.
- Unknown adoption. I have no data on external users, production deployments or community size. I have not counted them, and you should not assume there are many.
Is it right for you?
Consider Rullst if:
- you want a batteries-included Rust web framework, with server-rendered HTML, HTMX and JSON APIs, and secure defaults you don’t have to assemble;
- you are building a SaaS or an internal tool on SQLite, PostgreSQL or MySQL, with Stripe billing and email;
- you value documented limits and CI evidence over a large ecosystem;
- you are comfortable pinning an exact version and reading migration guides.
Look elsewhere, or wait, if:
- you need a long production track record, a large community or commercial support today; wait for 13.0.0 and a few stable 13.x releases at least;
- your frontend is a heavy client-side SPA, which Rullst deliberately does not center on;
- you depend on providers Rullst no longer ships (see the v13 migration guide), or on the Experimental packages for anything safety-critical;
- you prefer to assemble a minimal stack yourself; plain Axum with crates you choose may suit you better.
What would raise my confidence most
- A 13.x line with no breaking changes for months. The public-API review and the SemVer gate exist for this.
- A second maintainer.
GOVERNANCE.mdalready records a successor arrangement; an active co-maintainer would reduce the risk further. - Real-account validation of the OAuth, payment and deployment paths.
- External users and case studies, beyond the maintainer’s own applications.
- Fewer Experimental packages, each either promoted or retired with evidence.
About this page
This is a snapshot of 13.0.0-alpha.4 written on 30 October 2026. It is not rewritten silently. A later evaluation will be a new, dated page, or this page will say what changed and when.
Amended on 30 October 2026, before the 13.0.0-alpha.4 tag: three facts were updated to the work merged after the page was written: the workflow count (37 → 34), the CI job that runs the first-hour pages’ commands, and the fuzz-evidence policy. The assessment itself is unchanged.
The Rullst philosophy
Rullst exists to make ambitious software feel possible in Rust without hiding the language, the generated code, or the boundaries that keep an application safe. It aims to be productive and broad, but its convenience must remain inspectable: static dispatch, compile-time generation, typed errors, explicit middleware, and ordinary Rust escape hatches are deliberate choices.
A story that stayed with us
A widely reported account of Rust’s origin begins with a broken elevator in Graydon Hoare’s apartment building. Its software had crashed, leaving him to climb the stairs. Rust itself began as Hoare’s personal project in 2006 and grew into a language designed to combine systems-level performance with much stronger memory-safety guarantees.
The elevator account is an origin story reported by MIT Technology Review, not a claim that every elevator failure was a memory bug or that Rust can make all software infallible. The lesson Rullst takes from it is narrower and more useful: when an entire class of failure can be prevented by design, prevention is better than asking every developer to rediscover the same danger.
Why Venelouis started Rullst
Rullst also has its own practical origin. Long before modern AI systems became capable coding partners, Venelouis wanted to build an education platform. He first learned what was possible by operating a Moodle installation on a VPS. Later, Laravel and AI helped him create a far more ambitious education product than he had previously believed he could build.
That experience made Laravel’s greatest strength tangible: a framework can let one person concentrate on the product instead of repeatedly assembling its foundations. Venelouis then wanted to rebuild that kind of product in Rust to use fewer resources, gain performance, and inherit Rust’s stronger compile-time guarantees. He could not find a Rust framework that combined the particular Laravel-like product workflow, breadth, approachability, and explicit security boundaries he wanted. Rullst began as an attempt to build that missing bridge.
Rullst is not a Laravel clone, and Rust should not be forced to behave like PHP. The inspiration is the feeling that a complete product is within reach; the implementation follows Rust’s strengths rather than concealing them.
Why Rullst jumps from version 5 to version 12
The version jump records the history of the ecosystem rather than six hidden
major releases of the unified framework. When Venelouis began recreating
creio.eu in Rust with AI assistance, the Gemini model he was using at the
time told him that there was no direct Rust counterpart to Laravel Socialite
for the workflow he needed. Instead of abandoning that part of the product,
he created the first independent Rullst crate. That project became
rullst-connect, which still carries the identity and social-login mission of
that original work.
The crates initially evolved in separate repositories and at different
speeds. By the time the umbrella Rullst framework was at version 5,
rullst-connect had already reached version 11. Bringing the projects into one
monorepo made their dependency changes, cross-crate compatibility tests, and
coordinated releases easier to maintain. Aligning every publishable package on
one version line was the natural next step.
A clean reset to 1.0 might have described the beginning of this unified era, but a registry history cannot be reset that way: crates.io does not permit a published version to be overwritten or removed, and lower new versions would not erase the already published higher ones. Rullst therefore advances the whole ecosystem directly to version 12, monotonically beyond Connect’s version 11. From the version 12 line onward, coordinated releases can follow the usual Semantic Versioning sequence. The jump is thus an act of package alignment and honest continuity, not a claim that standalone Rullst versions 6 through 11 were released as complete framework generations.
Core tenets
-
Productive, not magical. Rullst coordinates routing, data, identity, security helpers, background work, AI, and developer tooling through APIs and generated source that users can inspect and replace.
-
Simple to begin, explicit when it matters. Good defaults should remove repetitive setup. Authorization, tenancy, provider behavior, persistence, deployment, and recovery must still expose their real application-owned decisions.
-
Prevent failure classes by design. Typed errors, bounded inputs, parameterized queries, compile-time generation, and fail-closed behavior are preferred to conventions that only work when every caller remembers them. This reduces risk; it does not create a universal security guarantee.
-
Built for humans and AIs. Stable vocabulary, focused modules, static contracts, executable examples, and documented limits give human developers and coding agents a shared map of the system. AI assistance never replaces review, testing, or accountable approval.
-
Evidence before claims. A feature claim must name its implemented scope and residual boundary. A test belongs to the revision and environment it exercised. Benchmarks describe measured workloads, not universal rankings.
-
Emotional productivity matters. The framework should make builders feel capable, curious, and supported. Removing boilerplate is valuable when it leaves more attention for users, learning, and the purpose of the product.
The ambition and the boundary
The long-term ambition is deliberately large: make Rullst a foundation from which people can build many kinds of applications without surrendering Rust’s performance or explicitness. That ambition is a direction, not a promise that one framework can finish every product, operate every provider, or replace the judgment of its developers.
Rullst will therefore grow through bounded, reviewable capabilities. Where the repository has proof, the documentation should show it. Where real devices, provider accounts, production operations, independent review, or application policy are still required, the documentation should say so plainly. The goal is not to look complete. It is to keep becoming more useful without losing the trust of the people building with it.
Simple capability status
This is the compact view of Rullst’s canonical M1–M41 programme. It is derived from the root ROADMAP; that roadmap and the capability ledger retain the evidence and limitations. The labels here deliberately do not turn partial foundations into completed features. Package stability is a separate question: the maturity tiers classify every crate as Core, Extension or Experimental and state what each tier promises within 13.x.
v12 stable snapshot — 15 September 2026
Stable 12.0.0 was published from commit eb11f892. See the
release record for the immutable tag and publication receipt. The
v12 line reached end of life on 16 October 2026 (12.3.0 was its last release);
this section keeps the record of its stable release.
The Rust CI scorecard for that commit awarded 94/100 (A) to the repository, with all fifteen non-IoT packages at A and IoT at its approved B exception. These are repository-owned audit scores constrained by test results, not a percentage of all planned functionality or an independent certification. The quality scorecard explains the scale.
The stable coverage run recorded the following local LLVM/LCOV gates before the Codecov upload:
| Coverage view | Stable-source measurement | Required floor |
|---|---|---|
| Whole repository | 90.3220% (79,813/88,365 lines) | 90% |
| Framework libraries | 90.6186% (60,014/66,227 lines across 436 files) | 90% |
These are distinct path sets, not values to average. The public Codecov badge tracks the current branch and may change as its source and report processing change. Do not relabel an older checkpoint as the latest measurement.
The release audit links the stable platform, property, Miri, Kani, fuzzing, sanitizer and publication results. New maintenance commits must satisfy their applicable gates; the published release cannot certify future source changes.
rullst facade versus rullst-core
| Package | Role | Typical user choice |
|---|---|---|
rullst-core | Low-level runtime engine: HTTP server, routes, lifecycle, queue/realtime, storage/cache and the default browser-security baseline. It deliberately does not aggregate every domain crate. | Use directly when a library/application wants only the runtime primitives and explicit dependencies. |
rullst | Ergonomic umbrella facade. Cargo features re-export Core plus selected ORM, Auth, Connect, Security, AI, Mail, Capital, Studio, Nexus, Messaging and IoT APIs (and, from the 13.0.0-alpha.1 prerelease, Privacy) through one dependency. It also exposes the browser/WASM surface used by web-first applications; PWA packaging (make:pwa) is a CLI workflow, not a re-exported crate. Its maturity cannot exceed the crates selected underneath it. | Use for most Rullst applications and enable only the required features. |
Documentation maintenance gate
Stable v12 completed the documentation release gate: the mdBook and Rust
snippets sourced from public tutorials built, local links and anchors were
validated, commands/features/version examples were reconciled with the frozen
manifests, and upgrade/provider boundaries were reviewed. v13 changes must
keep those checks green: the Documentation workflow builds the book, checks
llms.txt and validates local links and anchors, and the guides listed in
rullst/src/book_doctests.rs compile as doctests. A green documentation build proves
structural consistency, not that every external service or store workflow was
homologated.
Canonical milestones
| ID | Capability | Simple status |
|---|---|---|
| M1 | CLI and make:* generator matrix | 🟡 Still to implement — partial |
| M2 | Fast linkers and measured build-time improvements | 🟡 Still to implement — partial |
| M3 | Escape hatches, granular features, diagnostics, and ejection | 🟡 Still to implement — partial |
| M4 | make:resource and local error console | ✅ Implemented — scoped |
| M5 | mdBook, OpenAPI, and typed client generation | 🟡 Still to implement — partial |
| M6 | ORM parity and Turso/libSQL profile | 🟡 Still to implement — partial |
| M7 | Portable edge runtime, distributed data, and safe upgrades | 🟡 Still to implement — partial |
| M8 | Explain-and-approve index recommendations | ⏳ Still to implement — not started |
| M9 | Local auth, OAuth/OIDC, TOTP, passkeys, and WebAuthn | 🟡 Still to implement — partial |
| M10 | Mail, DTO validation, distributed rate limits, and Shield | 🟡 Still to implement — partial |
| M11 | Nexus, billing, and entitlements (Omni was retired in 13.0.0-alpha.3) | 🟡 Still to implement — partial |
| M12 | Defence-in-depth security programme | 🟡 Still to implement — continuous/partial |
| M13 | Audited PQC protocols and sandboxed Wasm extensions | ⏳ Still to implement — not started |
| M14 | HTMX-first SSR and real Leptos/Dioxus interoperability | 🟡 Still to implement — partial |
| M15 | Runtime queues/cache/scheduler plus brokered messaging | 🟡 Still to implement — local foundation and admitted standalone Redis profile; other adapters open |
| M16 | Wasm islands and #[client_component] protocol | 🟡 Still to implement — partial |
| M17 | Realtime, object storage, media, and packages | 🟡 Still to implement — partial |
| M18 | LiveView-style server-driven UI | 🟡 Still to implement — partial |
| M19 | Radar, agent schemas, spans, and Prometheus | ✅ Implemented — bounded |
| M20 | Persistent event stream and verifiable ledger semantics | ⏳ Still to implement — not started |
| M21 | Omni frontend protocol and mobile bridge | 🟡 Still to implement — partial; 13.0.0-alpha.3 replaced the Tauri shells with installable PWAs (make:pwa) |
| M22 | Human-reviewed agentic DevOps recommendations | 🟡 Still to implement — partial |
| M23 | Diagnostic auto-healing recommendations | 🟡 Still to implement — partial |
| M24 | no_std IoT frames/packet encoders, signed OTA gate, and durable-counter CAS boundary | 🟡 Still to implement — partial |
| M25 | Embassy-based async embedded integration | ⏳ Still to implement — not started |
| M26 | Guided deployment (cargo rullst deploy targets a Linux VM; the PaaS targets were removed in 13.0.0-alpha.4) | 🟡 Still to implement — partial |
| M27 | Kubernetes scaffolding and health/readiness probes | ✅ Implemented — scaffolding scope |
| M28 | Compile-time DI and Inject<T> | ✅ Implemented — foundation |
| M29 | Scalar playground and complete OpenAPI generation | 🟡 Still to implement — partial |
| M30 | Tonic/gRPC and Protobuf support | ⏳ Not implemented — the make:grpc generator was removed in 13.0.0-alpha.4; applications use their own tonic setup |
| M31 | Aerospace/autonomous/defence systems | ⏳ Separate safety-critical programme; outside the general framework suite |
| M32 | Axum/Tower escape hatches and proc-macro diagnostics | ✅ Implemented — bounded |
| M33 | Server-side declarative SaaS entitlements | 🟡 v13 prerelease foundation — typed current-state plan authorization with an authenticated SaaS consumer; the declarative #[rullst::gate]/GateGuard model remains open |
| M34 | Schema-driven TypeScript/React/Dart/Swift SDKs | 🟡 v13 prerelease foundation — one bounded OpenAPI 3.1 profile generates Rust codecs and a strict TypeScript client; React, Dart and Swift remain open |
| M35 | Distributed OpenTelemetry waterfall in Studio | 🟡 Still to implement — partial |
| M36 | Read-only explainable natural-language SQL assistant | ⏳ Still to implement — not started |
| M37 | Reviewable one-click error-console patch workflow | 🟡 v13 prerelease preview — the error page records a bounded panic context, makes no network request and hands the error to the developer’s own assistant through a copyable prompt and the recorded-context path (cargo rullst ai fix was removed in 13.0.0-alpha.4); a one-click flow and recording in the hot-reload mode remain open |
| M38 | Vendor-specific SQLite replica/synchronization profile | ⏳ Still to implement — not started |
| M39 | Optional self-hosted rullst-gateway load balancer | ⏳ Not implemented — abandoned on 8 October 2026 in favour of generated Caddy/nginx configuration; kept in the not-started count |
| M40 | rullst-labs contracts with a bring-your-own runner | 🟡 v13 prerelease foundation — trusted job/lease/grading contracts and a documented runner contract; the rullst-labs-runner candidate was removed from 13.0, so execution is application-owned; full offensive CTF arenas require external isolated infrastructure |
| M41 | Privacy controls and proportional age assurance | 🟡 v13 prerelease foundation — policy, signed evidence, asynchronous replay, SQLite and PostgreSQL adapters; combined hosted acceptance, consumer journeys and broader privacy work remain open |
The labels match the milestone rows of the root roadmap: 5 implemented,
27 partial, and 8 not started inside the 40-milestone framework programme. M31 is excluded
because it is a separately governed safety-critical programme. The 35 milestones
without strict closure have different sizes and overlap existing published
capabilities; their count does not measure remaining engineering effort or
release blockers. Assigning every partial row half credit would not establish
a completion percentage. The
v12 stable record preserves the completed release identity; new
capability work goes to v13. The
safe update experience is a top v13
priority; cargo rullst upgrade and the v13 migration guide
are its current tools. The
26 September delivery plan selects concrete increments
from this wider programme and records their acceptance requirements.
Claims that are impossible as framework guarantees
No useful capability above is dismissed merely because it is difficult. The
impossible label is reserved for absolute wording that code in this repository
cannot honestly establish:
| Absolute claim | Status |
|---|---|
| 100% uptime or zero data loss in every deployment | 🚫 Impossible as a framework guarantee |
| Exactly-once arbitrary external side effects | 🚫 Impossible without destination-level idempotency/transactions |
| Zero latency, zero overhead, zero allocations, or universal sub-100ms builds | 🚫 Impossible as a universal guarantee |
| Total memory safety or universal panic-freedom across dependencies, FFI, generated apps, and every input | 🚫 Impossible to prove from this repository alone |
| Automatic fiscal, security, privacy, App Store, or hardware certification | 🚫 Requires independent authorities, environments, and evidence |
| Universal one-click zero-downtime deployment | 🚫 DNS, credentials, migrations, providers, and rollback remain operational inputs |
| “Best/fastest/most secure framework in the world” | 🚫 Not a technical property without dated, reproducible comparative evidence |
| Unattended production mutation that is always safe | 🚫 Approval, scoped authority, audit, recovery, and application policy cannot be removed |
Use the quality scorecard for per-commit engineering evidence. It is intentionally separate from this functionality view.
Maturity tiers
Decision recorded on 8 October 2026 for the v13 line, before
13.0.0-alpha.1. Every Rullst package, and every CLI generator whose result
depends on an outside toolchain or platform, has one of three maturity tiers.
A tier tells you how stable a package is and how much of it has been validated.
It does not tell you whether a feature exists. For that, see the
capability status and the
capability ledger.
| Tier | Who needs it | Promise in one line |
|---|---|---|
| Core | Every application | Supported: stable within 13.x, with the widest platform validation. |
| Extension | Applications that opt in | Supported: the same 13.x stability promise, for the documented scope of each crate. |
| Experimental | Applications that opt in and accept change | May change between 13.x releases and is not validated in every scenario. |
Prerelease note. Until stable
13.0.0is published, every v13 package is a prerelease and may change in any prerelease.13.0.0-alpha.4is the 13.0 feature freeze:13.0.0-rc.1carries only fixes and documentation. The promises below begin with13.0.0.SECURITY.mdlists which published versions receive security fixes.
What “supported” promises
Core and Extension packages are supported. In practice, that means:
- Stability within 13.x. Documented public Rust APIs, Cargo feature names, CLI commands and flags, configuration keys and serialized contracts follow the compatibility policy. A 13.x minor or patch release does not intentionally break them. A removal is deprecated first and happens in a later major release. The policy’s security exception still applies: an unsound or unsafe API can be disabled sooner, with an advisory.
- Validation. The documented scope runs in the repository’s required checks:
tests, strict Clippy, feature boundaries, the zero-panic and unsafe policies,
dependency audits and package checks. The workspace runs on Linux for every
pull request and on the full Linux, macOS and Windows matrix nightly; Core,
the
rullstfacade and the macros also compile for WebAssembly. External services (payment, mail, AI and identity providers), real devices and production topologies count as validated only where the crate’s own documentation says so. - Support. Bug reports are triaged and fixes ship in a 13.x release. A known
regression in a Core package blocks the release that would contain it. An
Extension regression is fixed or documented as a known issue before release.
Security reports follow
SECURITY.md. Rullst has a sole maintainer (seeGOVERNANCE.md) and offers no paid support or response-time commitment beyondSECURITY.md.
Core vs Extension. The difference is reach, not quality. Core packages are
in every application’s dependency graph, so they get the widest platform
validation and the highest priority. Extensions are optional; you add them when
your application needs them. Within an Extension, the documentation of an
individual adapter or provider can give it a different label, and that label
wins. For example, rullst-capital supports Stripe and marks InfinitePay as
experimental.
What “experimental” promises
Experimental packages are optional and opt-in. Experimental does not mean broken. They pass the same gates as supported packages: tests, strict Clippy, the zero-panic and unsafe policies, dependency audits and, where applicable, fuzzing. What the label tells you:
- The API can still change. Public APIs, Cargo features, storage schemas and generated output may change between 13.x releases, including in ways that stop your code from compiling. Each breaking change is listed in the changelog and in the v13 migration guide.
- Some real-world scenarios are not validated yet. Each experimental package lists its gaps in the table below and on its own page. Examples: physical hardware, live provider accounts, real devices and multi-host deployments. Tests cover the documented contract; they do not certify those environments.
- Support is best effort. Bug reports and contributions are welcome, and
security reports are handled under
SECURITY.mdlike any other package. A security fix may change an experimental API. A non-security regression does not block a release.
To use an experimental package today, pin an exact version in Cargo.toml and
read the changelog before you upgrade. Check that the documented scope covers
what your application needs.
Packages
| Package | Tier | Scope notes |
|---|---|---|
rullst-core | Core | HTTP runtime, routing, lifecycle, queues/realtime and the default security baseline. |
rullst-macros | Core | html!, model and runtime macros. |
rullst-orm | Core | Relational models, migrations and transactions. Optional store adapters document their own validation boundaries. |
rullst-orm-macros | Core | Typed ORM code generation. |
rullst-security | Core | Defense-in-depth middleware, guards and audit helpers. |
rullst (facade) | Core | Its own API is Core. A feature that re-exports another crate has that crate’s tier: iot and every privacy feature are experimental. |
cargo-rullst | Core | Some generators have their own label; see CLI generators. |
rullst-auth | Extension | Passwords, sessions, passkeys and authorization helpers. |
rullst-connect | Extension | OAuth2/OIDC identity integrations. |
rullst-mail | Extension | Transactional email and delivery controls; see the crate page for the supported drivers. |
rullst-capital | Extension | Payments and billing: Stripe (supported) and InfinitePay (experimental). |
rullst-ai | Extension | Guarded LLM clients and retrieval; provider coverage is in the AI provider matrix. |
rullst-nexus | Extension | Admin CMS for registered models. |
rullst-studio | Extension | Local developer control room. |
rullst-messaging | Extension | Broker-neutral messaging, durable local state and the Redis Streams profile. |
rullst-iot | Experimental | Not validated on physical hardware and has no network transport. See the module status. OtaManager’s public fields become getters in a 13.x minor release. |
rullst-privacy | Experimental | First published in the 13.0.0-alpha.1 prerelease. No live age providers or verified guardianship; see the privacy roadmap. |
rullst-supervision | Experimental | The host owns capture, models and reviewer workflow. Read Responsible use first. |
rullst-media | Experimental | Interoperability with real Bunny Stream accounts, transcoding and CDN delivery is unvalidated; the opt-in s3 adapter (S3, R2, MinIO) is validated against the offline mock only. See the managed-video candidate. |
rullst-labs | Experimental | In the 13.0 release inventory since 13.0.0-alpha.1. Contracts only; it never executes learner code. Bring your own runner; see the runner contract. |
CLI generators
cargo-rullst is a Core package, and commands without a label here have the
CLI’s Core tier, within the boundaries described for each command in the
CLI reference. A command that the reference marks as a v13
preview or candidate is not yet in a stable release. A generator that adds an Extension or
Experimental crate to your application cannot promise more than that crate. For
example, make:privacy and make:age-gate generate consumers of the
experimental rullst-privacy.
| Generator | Commands | Tier | Why |
|---|---|---|---|
| IoT | make:iot | Experimental | Generates a telemetry module on top of the experimental rullst-iot. |
| Green report | green | Experimental | Measured size, memory, dependency and benchmark CPU figures and a labelled SCI estimate; its files are in the pull-request mutation gate. See the green guide. |
For an experimental generator, the generated files, their layout, and the command’s flags may change between 13.x releases. Review the diff when you regenerate.
13.0.0-alpha.4 removed the experimental Foundry, Nix, Buildah and gRPC
generators and the Fly.io, Railway and Render targets of deploy; see
Advanced deployment for those setups. It also removed
the terminal AI assistant (cargo rullst ai; use your own assistant with the
project’s AGENTS.md and the site’s
llms.txt) and renamed
footprint to green.
cargo rullst new offers three starters with the CLI’s Core tier: Blank, API
and SaaS. 13.0.0-alpha.4 moved the LMS, Blog, Portfolio and ERP starters out of
the CLI; the LMS, ERP, Portfolio and Blog applications in examples/ replace
them. These examples are applications to copy from and carry no
tier.
How a tier changes
- Promotion (Experimental to Extension) can happen in any 13.x minor release. It requires recorded evidence for the gaps listed above, an API expected to stay stable, complete documentation and a maintainer who commits to supporting it. The changelog and this page record each promotion.
- A supported package keeps its promise for the rest of 13.x. Moving it to Experimental, or retiring it, is a major-release change. It is announced through deprecation, as the compatibility policy requires.
- An experimental package may be redesigned, extracted or retired in a later release. Any such change appears in the changelog and migration guide.
The v13 maintenance scope explains the investment decisions behind these tiers. For the two Core security crates, see which security layer to use, and when.
Zero to a complete app
What you will build: Notes, a small web app where people register, sign in and keep private notes. Along the way you will add a model and its migration, CRUD pages with HTMX and form validation, routes that only a note’s owner can open, tests, a security report, a development loop and a production build. Plan for about an hour, most of it waiting for the first build.
This page is one path from an empty folder to a working app. Each step links to the tutorial that explains it in depth.
How this page was recorded. The commands and outputs come from a run on 8 October 2026 with the 13.0 development CLI (
13.0.0-alpha.1), SQLite and Linux. On 29 October 2026 the steps that need no build were repeated with13.0.0-alpha.4: creating the app, the two generators, the security report and the IDOR check printed what this page shows, and the files underzero-to-complete-app/follow that release’s SaaS starter. The test list in step 6 is taken from the alpha.4 sources; build times and sizes are from the first run. Output is shortened with…, and your timestamps will differ.
1. Install Rust and the CLI
Install Rust from rustup.rs, then the v13 prerelease CLI. Cargo never selects a prerelease on its own, so name the exact version, as described in Getting Started:
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
2. Create the app from the SaaS starter
The SaaS starter already has registration, sign-in, a session middleware, CSRF protection, security headers and Stripe billing that runs offline until you add real keys, so you can focus on your own feature. Create it with SQLite, which needs no database server:
cargo rullst new notes --default --blueprint saas --database sqlite
cd notes
The first build compiles every dependency and takes several minutes (see Build times for measured figures). It ends with:
📦 Bootstrapping Database...
✅ Database tables created successfully.
✅ Initialized a Git repository (.gitignore keeps .env and target/ out).
✨ Project 'notes' created successfully!
Application profile: Zero-Bundle HTMX (html! SSR)
ORM profile: Active Record
Next steps
1 cd notes
2 cargo rullst dev builds, applies migrations and serves with live reload
3 open http://127.0.0.1:3000 your generated welcome page
Prefer a live dashboard? cargo rullst dash · new to Rullst? cargo rullst tour
Commit the generated files now. The security
report in step 7 reads the files Git tracks, and the generated .gitignore
already leaves out .env and the SQLite database:
git add -A
git commit -m "Generated SaaS starter"
3. Add a model with a migration
cargo rullst make:model Note --migration
🛠️ Generating Rullst model: Note...
✨ Model 'Note' successfully created at 'src/models/note.rs'!
✨ Rust migration successfully created at 'src/migrations/m20261008071503_create_notes.rs'!
…
Next steps
→ cargo rullst db:migrate apply the new migration
→ cargo rullst make:controller Note serve the model over HTTP
Give the model its fields in src/models/note.rs. user_id records who wrote
the note; it always comes from the session, never from the form. Keep the
snapshot field the generator added: with it, save() of a loaded note
writes only the columns that changed:
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "notes")]
pub struct Note {
pub id: i32,
/// The id of the user who wrote the note; set from the session, never from the form.
pub user_id: i32,
pub title: String,
pub body: String,
// Remembers the loaded values, so `save()` writes only the columns this
// record changed. Build new records with `snapshot: RowSnapshot::default()`.
#[sqlx(skip)]
pub snapshot: RowSnapshot,
}
Add the same columns to the generated migration,
src/migrations/m<timestamp>_create_notes.rs:
use rullst::db::schema::{Schema, Migration};
use rullst::db::async_trait;
pub struct MigrationImpl;
#[async_trait]
impl Migration for MigrationImpl {
fn name(&self) -> &'static str {
"m20261008071503_create_notes"
}
async fn up(&self) -> Result<(), rullst::orm::Error> {
Schema::create("notes", |table| {
table.id();
table.integer("user_id").not_null();
table.string("title").not_null();
table.string("body").not_null();
table.timestamps();
}).await
}
async fn down(&self) -> Result<(), rullst::orm::Error> {
Schema::drop_if_exists("notes").await
}
}
Apply it:
cargo rullst db:migrate
⏳ Running 'cargo run -- db:migrate'...
…
Migrating: m20261008071503_create_notes
Migrated: m20261008071503_create_notes
More on models and schema changes: Active Record CRUD and migrations.
4. CRUD pages with HTMX and validation
Generate the controller. Its form checks use the derive macro of the
validator crate, which the starter already declares at the version Rullst
uses, 0.21:
cargo rullst make:controller Notes
Replace the generated placeholder in src/controllers/notes_controller.rs
with the complete controller:
use rullst::db::RowSnapshot;
use rullst::html::Markup;
use rullst::security::{CspNonce, CsrfToken};
use rullst::server::{Extension, IntoResponse, Path, Redirect, Response, StatusCode};
use rullst::{html, HtmxRequest, HtmxResponse, Validate, ValidatedForm};
use rullst_security::{RbacGuard, UserContext};
use serde::Deserialize;
use crate::models::note::Note;
/// The fields a user may submit. `user_id` is not here: it comes from the session.
#[derive(Debug, Deserialize, Validate)]
pub struct NoteForm {
#[validate(length(min = 1, max = 120, message = "Give the note a title (1 to 120 characters)."))]
pub title: String,
#[validate(length(max = 5000, message = "Keep the note under 5000 characters."))]
pub body: String,
}
/// Lets only the note's owner through. Another user's note answers 404, so
/// its existence is not revealed.
pub fn authorize(user_id: i32, note: &Note) -> Result<(), StatusCode> {
let user = UserContext::new(user_id.to_string(), Vec::new());
RbacGuard::authorize_owner_or_role(&user, ¬e.user_id.to_string(), "admin")
.map_err(|_| StatusCode::NOT_FOUND)
}
async fn find_owned(user_id: i32, id: i32) -> Result<Note, StatusCode> {
let note = Note::find(id)
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?
.ok_or(StatusCode::NOT_FOUND)?;
authorize(user_id, ¬e)?;
Ok(note)
}
/// After a successful write, HTMX gets an `HX-Redirect`; a plain form post a 303.
fn back_to_list(htmx: &HtmxRequest) -> Response {
if htmx.is_htmx {
HtmxResponse::new("").redirect("/notes").into_response()
} else {
Redirect::to("/notes").into_response()
}
}
fn page(nonce: &CspNonce, title: &str, content: Markup) -> Markup {
html! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<link rel="stylesheet" href="/static/rullst.css" />
<script nonce={nonce.as_str()} src="/static/htmx-1.9.12.min.js"></script>
</head>
<body><main class="container">{content}</main></body>
</html>
}
}
fn note_form(action: &str, csrf: &CsrfToken, title: &str, body: &str, button: &str) -> Markup {
html! {
<form method="post" action={action} hx-post={action} hx-target="#errors">
<input type="hidden" name="_token" value={csrf.as_str()} />
<label for="title">"Title"</label>
<input id="title" name="title" value={title} required />
<label for="body">"Note"</label>
<textarea id="body" name="body">{body}</textarea>
<div id="errors" role="alert"></div>
<button type="submit">{button}</button>
</form>
}
}
/// GET /notes: the signed-in user's notes and a form to add one.
pub async fn index(
Extension(user_id): Extension<i32>,
Extension(csrf): Extension<CsrfToken>,
Extension(nonce): Extension<CspNonce>,
) -> Result<Markup, StatusCode> {
let notes = Note::query()
.where_eq("user_id", user_id)
.order_by_desc("id")
.get()
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
let items: Markup = notes
.iter()
.map(|note| {
let href = format!("/notes/{}", note.id);
html! { <li><a href={href}>{note.title.as_str()}</a></li> }
})
.collect();
let content = html! {
<h1>"My notes"</h1>
<ul id="notes">{items}</ul>
<h2>"New note"</h2>
{note_form("/notes", &csrf, "", "", "Add note")}
};
Ok(page(&nonce, "My notes", content))
}
/// POST /notes: validates the form, then saves a note owned by the current user.
pub async fn store(
htmx: HtmxRequest,
Extension(user_id): Extension<i32>,
ValidatedForm(form): ValidatedForm<NoteForm>,
) -> Result<Response, StatusCode> {
let mut note = Note {
id: 0,
user_id,
title: form.title,
body: form.body,
snapshot: RowSnapshot::default(),
};
note.save().await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
Ok(back_to_list(&htmx))
}
/// GET /notes/{id}: one note, with edit and delete forms. Owner only.
pub async fn show(
Path(id): Path<i32>,
Extension(user_id): Extension<i32>,
Extension(csrf): Extension<CsrfToken>,
Extension(nonce): Extension<CspNonce>,
) -> Result<Markup, StatusCode> {
let note = find_owned(user_id, id).await?;
let action = format!("/notes/{id}");
let delete = format!("/notes/{id}/delete");
let content = html! {
<p><a href="/notes">"← All notes"</a></p>
<h1>{note.title.as_str()}</h1>
{note_form(&action, &csrf, ¬e.title, ¬e.body, "Save")}
<form method="post" action={delete.as_str()}>
<input type="hidden" name="_token" value={csrf.as_str()} />
<button type="submit">"Delete"</button>
</form>
};
Ok(page(&nonce, ¬e.title, content))
}
/// POST /notes/{id}: validates and saves changes. Owner only.
pub async fn update(
htmx: HtmxRequest,
Path(id): Path<i32>,
Extension(user_id): Extension<i32>,
ValidatedForm(form): ValidatedForm<NoteForm>,
) -> Result<Response, StatusCode> {
let mut note = find_owned(user_id, id).await?;
note.title = form.title;
note.body = form.body;
note.save().await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
Ok(back_to_list(&htmx))
}
/// POST /notes/{id}/delete: deletes the note. Owner only.
pub async fn destroy(
htmx: HtmxRequest,
Path(id): Path<i32>,
Extension(user_id): Extension<i32>,
) -> Result<Response, StatusCode> {
let note = find_owned(user_id, id).await?;
note.delete().await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
Ok(back_to_list(&htmx))
}
#[cfg(test)]
mod tests {
use super::*;
fn note_of(user_id: i32) -> Note {
Note {
id: 1,
user_id,
title: "Groceries".into(),
body: String::new(),
snapshot: RowSnapshot::default(),
}
}
#[test]
fn only_the_owner_may_open_a_note() {
assert_eq!(authorize(7, ¬e_of(7)), Ok(()));
assert_eq!(authorize(8, ¬e_of(7)), Err(StatusCode::NOT_FOUND));
}
#[test]
fn an_empty_title_is_rejected() {
let form = NoteForm { title: String::new(), body: "text".into() };
assert!(form.validate().is_err());
let form = NoteForm { title: "Groceries".into(), body: String::new() };
assert!(form.validate().is_ok());
}
}
What it does:
indexlists only the signed-in user’s notes and renders a form.store,updateanddestroysave, change and delete notes;showopens one.ValidatedForm<NoteForm>rejects an empty or overlong title before the handler runs. For an HTMX request it answers with an error fragment, whichhx-target="#errors"places under the form; the REST status travels in theX-Rullst-Validation-Statusheader.- After a successful write, HTMX receives an
HX-Redirectback to the list, and a plain form post a303redirect, so the forms work without JavaScript too. html!escapes the values it interpolates: a note titledBuy <milk>is rendered asBuy <milk>. It returnsMarkup, sonote_formand the collected list items are inserted into the page as HTML, and the handlers return the page directly.
Now mount the routes in the router() function of src/main.rs. Add them
before the csrf_middleware layer, so every form post needs the CSRF token
the pages include, and wrap each one in the starter’s auth_middleware:
// Notes: signed-in users only. Each note belongs to the user who wrote it.
.route("/notes", rullst::routing::get(controllers::notes_controller::index)
.post(controllers::notes_controller::store)
.layer(rullst::server::from_fn(middlewares::auth_middleware::auth_middleware)))
// rullst-access: owner — find_owned() calls RbacGuard::authorize_owner_or_role
.route("/notes/{id}", rullst::routing::get(controllers::notes_controller::show)
.post(controllers::notes_controller::update)
.layer(rullst::server::from_fn(middlewares::auth_middleware::auth_middleware)))
// rullst-access: owner — find_owned() calls RbacGuard::authorize_owner_or_role
.route("/notes/{id}/delete", rullst::routing::post(controllers::notes_controller::destroy)
.layer(rullst::server::from_fn(middlewares::auth_middleware::auth_middleware)))
.layer(rullst::server::from_fn(rullst::security::csrf_middleware))
The whole file is in zero-to-complete-app/main.rs.
More on HTMX rendering,
forms and validation and
routing.
5. Sign-in and owner-only routes
Sign-in comes from the starter: /register and /login set an encrypted
session cookie, and auth_middleware turns it into the user id that the
handlers read as Extension<i32>. Anyone else is redirected to /login. The
cookie carries the account’s session epoch: the dashboard’s Sign out
everywhere button (POST /logout/everywhere) increments it, which ends the
session on every device. The starter also has Change password
(/password/change), which keeps only the current browser signed in, and
Forgot your password? (/password/forgot), which emails a single-use link
that expires in 30 minutes (RESET_TOKEN_LIFETIME_MINUTES, from 10 minutes
to 24 hours); see
Tutorial 11.
Being signed in is not enough to open a note, though: /notes/{id} must
check that the note belongs to that user. Otherwise anyone could read another
user’s note by changing the number in the URL (an IDOR). find_owned loads
the note and calls RbacGuard::authorize_owner_or_role; another user’s note
answers 404, so it does not even reveal that the note exists.
The // rullst-access: owner — reason comment above each route with an {id}
records that decision. cargo rullst audit --idor and the security report in
step 7 fail when a route with a path parameter has no such comment, or when a
non-public classification has no matching guard in the code.
With two accounts, Alice and Bob, the recorded run observed:
| Request | Result |
|---|---|
GET /notes without signing in | 303 to /login |
Alice: GET /notes/1 (her note) | 200 |
Bob: GET /notes/1 | 404 |
Bob: POST /notes/1/delete | 404, and the note is kept |
POST /notes without the CSRF token | 403 |
| Alice: create with an empty title (HTMX) | 200 with X-Rullst-Validation-Status: 422 and the message Give the note a title (1 to 120 characters). |
More on authentication and ownership, RBAC and IDOR.
6. Run the tests
Every new project ships src/security_tests.rs. Its tests
build the app from router(), wrap it in the baseline that Server adds in
production, and check offline that responses carry the security headers, that
a write is refused without the CSRF token and accepted with it, and that the
WAF refuses an injection probe but accepts ordinary prose. The SaaS starter
also checks that repeated sign-in attempts are rate limited. The tests request
/health, /logout and /login; your notes routes sit behind the same
baseline because they are part of router(). The starter’s sign-in
controller and its Nexus access check have unit tests of their own.
The notes controller adds two unit tests: one proves that only the owner
passes authorize, the other that an empty title fails validation. None of
these tests needs a database or the network:
cargo test
running 9 tests
test controllers::auth_controller::tests::length_limits_count_what_the_form_counts ... ok
test controllers::auth_controller::tests::registration_timestamps_are_current_utc_instants ... ok
test controllers::notes_controller::tests::only_the_owner_may_open_a_note ... ok
test controllers::notes_controller::tests::an_empty_title_is_rejected ... ok
test middlewares::nexus_access::tests::a_request_without_a_session_is_sent_to_sign_in ... ok
test security_tests::production_responses_carry_the_security_headers ... ok
test security_tests::the_waf_refuses_injection_and_accepts_prose ... ok
test security_tests::csrf_refuses_a_write_without_the_token ... ok
test security_tests::repeated_sign_in_attempts_are_rate_limited ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in …
The order of the lines can differ from run to run.
7. Check the app with the security report
cargo rullst audit --report
Rullst security report (OWASP ASVS 5.0.0 Level 1 mapping)
Evidence for a reviewer; it does not replace a manual review or a penetration test.
Security checks
✓ Security headers and CSP — no findings
✓ CSRF — no findings
✓ Session and auth cookies — no findings
✓ Rate limiting on authentication routes — no findings
✓ Committed secrets — no findings
✓ Vulnerable dependencies — no findings
✓ IDOR / BOLA route classification — no findings
Personal data
! Inventory — 2 field(s) listed, 2 need review (name heuristic)
Accessibility checks
✓ Images have a text alternative — no findings
✓ Form controls have a label — no findings
✓ Pages declare their language — no findings
NOT EVALUATED: 63 of 70 ASVS Level 1 requirements are outside these static checks.
Report written to SECURITY_REPORT.md
SECURITY_REPORT.md lists the two fields to review: the email columns of
the starter’s User and BillingCustomer models. The vulnerable-dependency
check needs cargo-audit installed. To see the IDOR check work, delete one
rullst-access comment and run cargo rullst audit --idor: it fails and
names the route. See the security report guide.
8. Develop with live reload
cargo rullst dev
Tip: run `cargo rullst dash` for live requests/s, latency, errors and database metrics.
Building the application...
Running initial db:migrate...
Nothing to migrate.
📊 Rullst Studio running on http://127.0.0.1:5555
🚀 SaaS server starting on port 3000...
…
Rullst framework serving on http://127.0.0.1:3000
…
Auto-reload: watching source, assets and configuration; successful builds restart the application.
Open http://127.0.0.1:3000/register, create an account, then go to
http://127.0.0.1:3000/notes. Saving a source file rebuilds and restarts the
app; a failed build keeps the previous one running. cargo rullst dash runs
the same loop inside a terminal dashboard with live requests, latency, errors
and database metrics. When one request repeats the same ORM
operation three or more times, the dashboard adds a POSSIBLE N+1 QUERIES
panel with the route, the operation and the count; see the
N+1 query warning. Stop either with
Ctrl+C. See
supervised auto-reload and the
dash reference.
9. Build for production and deploy
cargo rullst build
🚀 Starting Rullst production build pipeline (Release Mode: true)...
⚙️ Executing cargo build --release...
Finished `release` profile [optimized] target(s) in 5m 48s
📦 Pre-compressing static assets in static/ directory...
…
✨ Pre-compression finished: processed 2 files, generated 2 .br files and 2 .zst files.
🎉 Rullst production build completed successfully!
The release binary is target/release/notes (15.3 MiB in the recorded run).
Cargo builds it with the [profile.release] that
cargo rullst new wrote into Cargo.toml: thin LTO, one codegen unit and
stripped symbols. panic stays unwind, so one panicking handler cannot stop
the server.
The build also writes Brotli and Zstandard copies of the files in static/,
which the server prefers; run it again after changing an asset. The
/static mount also sets cache headers. A file name with a content
hash, such as app.3f9a2c1b.css, is cached for a year as immutable; other
files, such as the starter’s rullst.css, get Cache-Control: no-cache and
are revalidated with their ETag. Started with the development .env, the
binary answered 200 on /health, redirected /notes to /login, and served
/static/rullst.css Brotli-compressed with no-cache and an ETag.
Measure the build with cargo rullst green (Experimental).
On its own, cargo rullst green --path /health starts the release binary with
RULLST_ENV=production, which the SaaS starter refuses until the production
billing settings exist:
error: App could not be started
What happened the app exited during start-up (exit status: 1).
Last stderr lines:
Error: BillingConfigError("production billing requires real credentials, a strong webhook secret, an HTTPS redirect URL, and an explicit plan allowlist")
Until then, either pass --env staging, which the starter accepts with its
mock billing credentials, or start ./target/release/notes with the
development .env and measure it from a second terminal:
cargo rullst green --url http://127.0.0.1:3000 --path /health --bench
The report lists what it measured, each with its method: the binary size of
the running executable, the idle resident memory, the number of third-party
packages in Cargo.lock and, because of --bench, the app’s CPU time during
a 10-second loopback load and the CPU time per request. The container image
size is NOT MEASURED until you name an image with --image. The figures
describe one run on one machine, with the load generator on the same machine;
compare runs only on the same setup. green measures no energy: a carbon figure
appears only as an SCI estimate from your own --cpu-watts and
--grid-intensity. See efficiency figures for an app.
Before it serves real users:
- create a production environment file with
RULLST_ENV=production, a newAPP_KEYand the billing settings described in the generatedBILLING.md; never reuse the development.env; - check that file with
cargo rullst deploy:doctor --env-file .env.production. Run against the development.env, it fails with[FAIL] environment_target, because that file does not setRULLST_ENV=production; - choose a platform: guided container deployment
(
cargo rullst deploywrites aDockerfile,compose.yamlandCaddyfilefor a Linux VM; see Deploy to a Linux VM), Kubernetes, or a container fromcargo rullst dockerize. Run migrations once per release, before starting the new version.
Where next
- Read the generated
AGENTS.mdbefore asking an AI assistant to change the app, and see the CLI reference for every command. - Add background jobs, email or real billing. Work that can wait, such as reports and exports, can be deferred to an allowed time window.
- Before launch, read the security architecture and check each crate’s maturity tier.
Tutorial 01: From Zero to Hello Rullst 🚀
What you will build: one complete web application with a typed route and server-rendered HTML. No database account or AI provider is needed. By the end, you will be able to point to the handler that produced the page in your browser.
Choose a different starting path · Next: CLI generators
This tutorial takes a new developer from installing Rust to a running Rullst
web application. It targets the v13 prerelease 13.0.0-alpha.4. The v12 line
reached end of life on 16 October 2026 (see the release record);
existing v12 applications follow the v13 migration guide.
1. Install Rust and Cargo
On Linux or macOS, use the official rustup installer:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
On Windows, download rustup from rustup.rs or run:
winget install --id Rustlang.Rustup
Restart the terminal if necessary, then verify the toolchain:
rustc --version
cargo --version
Rullst v13 requires Rust 1.96.0 or newer, the MSRV recorded in the
compatibility policy. Run rustup update if
rustc --version reports an older release.
2. Create a project
cargo new my_first_app
cd my_first_app
Every command below must run in this directory, where Cargo.toml lives.
3. Add Rullst
Add the v13 prerelease from crates.io. Name its exact version: Cargo never
selects a prerelease on its own, and the v12 line reached end of life on
16 October 2026, so a plain cargo add rullst would pick an unmaintained 12.x:
cargo add rullst@=13.0.0-alpha.4
cargo add tokio --features full
Cargo records the resolved dependency graph in Cargo.lock. Commit that file
for an application so its checkout remains reproducible, and review future
dependency updates before deployment.
4. Define the first route
Replace src/main.rs with:
use rullst::{html, routes, Markup, Server};
async fn home() -> Markup {
html! {
<div class="min-h-screen bg-slate-900 text-emerald-400 flex flex-col items-center justify-center font-sans">
<h1 class="text-5xl font-extrabold mb-4">"Hello, Rullst! 📜🦀"</h1>
<p class="text-slate-400 text-lg">"Your first typed route is running."</p>
</div>
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = routes![
get("/" => home)
];
Server::new(app).run(3000).await?;
Ok(())
}
The fallible server startup uses ?, so bind and runtime failures are returned
to the process instead of causing a framework-originated panic.
5. Run the application
Start the server:
cargo run
The first build compiles every dependency and can take several minutes
(Build times). When the terminal prints
Rullst framework serving on http://127.0.0.1:3000, open
http://127.0.0.1:3000. Stop the server with Ctrl+C.
Make it yours: change the heading to your project name, restart with
cargo run, and confirm the response changed. The example’s utility class
names do not install Tailwind by themselves; seeing an unstyled page is not a
server failure. The Blank and SaaS starters of the CLI ship a local stylesheet
(static/rullst.css).
If the port is already occupied, stop the conflicting process you own or start
on another port with PORT=3001 cargo run: the PORT variable (from the
environment, then .env) takes precedence over the .run(3000) argument. Keep
the terminal output: it is the first place to look for a build or startup
diagnostic.
6. Continue with the CLI
The CLI can generate complete starters and project modules. Install the v13 prerelease from crates.io (name the exact version; Cargo never picks a prerelease on its own):
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
cargo rullst --version
The second command prints rullst 13.0.0-alpha.4. Tutorial 02
creates a project with it; the hand-written my_first_app is not needed there.
The CLI’s new generator targets the CLI’s own framework version. A CLI built
from a source checkout of main is the v13 development line: it emits absolute
path dependencies to that exact checkout, including when invoked elsewhere.
Keep such a checkout in place and review its sources before sharing a generated
project. See the CLI reference for every command and
boundary.
Key takeaways
- The
rullst::html!macro generates ordinary Rust string-building code and escapes dynamic values. It returnsMarkup, which a handler returns astext/htmland anotherhtml!inserts as HTML. Rendering still performs the allocations/work implied by the generated template. - Boolean HTML attributes inside
html!are written bare (required) or conditionally (checked?={is_done}writescheckedor nothing). Presence is the value, sodisabled="false"would still disable the control. - Fallible handlers can return
Result<Response, YourAppError>using an application-defined error that converts the relevant typed framework/domain errors; server startup propagatesServerErrorwith?. - The moving
mainbranch is an integration source, not an immutable release artifact. For a reproducible application, pin the exact crates.io version (=13.0.0-alpha.4) and commitCargo.lock.
Create Your First Rullst REST API
Your result: a typed JSON response you can verify from another terminal. Start here when your frontend is a separate app, mobile client or integration. Explore all beginner paths or the complete API learning-path index.
This quickstart creates one runnable JSON endpoint without a database or an AI
provider. It targets the v13 prerelease 13.0.0-alpha.4; the v12 line reached
end of life (see the release record). You need Rust 1.96.0 or
newer (Tutorial 01 shows how to
install it).
1. Create the application
cargo new first_rullst_api
cd first_rullst_api
cargo add rullst@=13.0.0-alpha.4
cargo add tokio --features macros,rt-multi-thread
cargo add serde --features derive
The exact requirement keeps this tutorial on the v13 prerelease (Cargo never
selects a prerelease on its own, and the v12 line reached end of life on
16 October 2026). Commit the generated
Cargo.lock so subsequent locked builds use the same resolved package versions;
it does not pin an unrelated moving Git branch or automatically update the app.
2. Add a JSON route
Replace src/main.rs with:
use rullst::{Server, ServerError, routes, server::Json};
use serde::Serialize;
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
framework: &'static str,
}
async fn health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
framework: "Rullst",
})
}
#[tokio::main]
async fn main() -> Result<(), ServerError> {
let app = routes![
get("/api/health" => health),
]
.layer(rullst::server::from_fn(
rullst::security::headers_middleware,
));
Server::new(app).run(3000).await
}
The response is serialized from a typed Rust value. The secure-header layer is included explicitly because transport, proxy, authentication, authorization, CSRF, and application-specific input policy remain deployment responsibilities; a JSON response alone is not a production security boundary.
3. Run and verify it
Start the application:
cargo run
From another terminal, request the route:
curl -i http://127.0.0.1:3000/api/health
The body is:
{"status":"ok","framework":"Rullst"}
Stop the server with Ctrl+C.
4. Prefer a generated API foundation?
After installing the v13 CLI, it can scaffold a headless API directly. Use a different directory from the hand-written example above:
cargo rullst new generated_api --default --api --no-database \
--skip-initial-migration
cd generated_api
cargo run
This is the API starter (the Blank starter without HTML). Its routes are
registered in router() in src/main.rs, not copied from the health handler
above: GET / answers with a JSON welcome message (curl http://127.0.0.1:3000/),
POST /api/messages requires Authorization: Bearer <API_TOKEN> with the
random token written to .env, and /health and /ready serve container
probes. Use cargo rullst make:controller project --api for additional JSON
controllers. Generated parameterized data routes still require explicit
ownership or tenant authorization; see RBAC and IDOR protection.
Tutorial 02: CLI Automation & Generators ⚡
Your goal: generate a small piece of the application, find the resulting
files and understand what still needs your code. You need the CLI installed in
Tutorial 01 (cargo rullst --version
prints rullst 13.0.0-alpha.4).
Rullst provides opinionated code generators (make:*) for controllers, models,
migrations, and resources. The current generators register generated Rust
modules when possible; they do not add application routes. make:controller
and make:model also refresh the generated project map (.llms.txt,
.rullst/context-map.json) and diagram.md; hand-written versions of those
files are kept. Review every generated file and mount the intended routes
yourself.
Create the project
Create a Blank starter with SQLite and enter it:
cargo rullst new shop --default --blueprint blank --database sqlite \
--skip-initial-migration
cd shop
--default skips the wizard’s questions. The project has Cargo.toml,
Rullst.toml, .env, src/main.rs (the routes, in router()) and a first
users migration. Run every command below from this directory; the following
tutorials use the same project unless they say otherwise. To see the welcome
page, run cargo rullst dev and open http://127.0.0.1:3000; the first build
can take several minutes.
🛠️ Essential Scaffolding Commands
Run the three commands in order: each one adds different files.
1. Generate a Controller
cargo rullst make:controller ProductsController
Creates src/controllers/products_controller.rs with placeholder index,
show, store, update, and delete handlers and registers the module
(pub mod controllers; in src/main.rs). Mount its page by adding a line to
the routes![...] list in router():
get("/products" => controllers::products_controller::index),
After cargo rullst dev rebuilds, http://127.0.0.1:3000/products shows the
generated placeholder page.
2. Generate a Model & Migration
cargo rullst make:model Product --migration
Creates the model in src/models/product.rs and a timestamped Rust migration
(src/migrations/m<timestamp>_create_products.rs) that src/migrations/mod.rs
registers. The model starts with an id and a snapshot field, the
migration with id and timestamps; Tutorial 03
adds the columns. cargo rullst dev applies pending migrations when it starts;
cargo rullst db:migrate applies them without starting the server
(Tutorial 05).
3. Generate a Full-Stack Resource Starting Point
cargo rullst make:resource Category
Scaffolds a model (src/models/category.rs), its migration, a controller
(src/controllers/category_controller.rs) and HTML view placeholders
(views/category/index.html and views/category/form.html) in one command.
Use a name that has no model yet: for an existing model or table the command
skips the create-table migration. The generated controller is not a complete
authorized CRUD implementation.
🔍 Static CLI Code Inspection
Inspect recognizable source declarations without launching a server:
# Inspect recognizable routes! declarations
cargo rullst inspect routes
# Print ORM models and field types
cargo rullst inspect models
Add --json for a versioned machine-readable document (see the
CLI reference).
💡 Key Takeaways
- Scaffolding generators maintain architectural consistency across team members.
cargo rullst inspectperforms bounded source-text inspection. Route output recognizes explicit single-lineroutes!entries (get,post,putanddelete); model output lists public declarations fromsrc/models. It is not complete macro expansion or a Rust semantic analysis.- Generators never add routes: mount each handler in
router()yourself.
Tutorial 03: Active Record CRUD Operations 🗄️
rullst-orm derives a typed query builder and persistence methods from a Rust
struct. The SQLx-backed model uses an i32 primary key named id; a zero value
means that save() inserts, while a non-zero value means that it updates.
This tutorial continues in the shop project of
Tutorial 02, whose cargo rullst make:model Product --migration wrote src/models/product.rs and
src/migrations/m<timestamp>_create_products.rs.
Step 1: Define an Active Record model
Give the generated migration the columns the model will use. In its up():
Schema::create("products", |table| {
table.id();
table.string("name").not_null();
table.integer("price_cents").not_null();
table.timestamps();
}).await
Then add the matching fields to src/models/product.rs:
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
pub struct Product {
pub id: i32,
pub name: String,
pub price_cents: i32,
// Remembers the loaded values, so `save()` writes only the columns this
// record changed. Build new records with `snapshot: RowSnapshot::default()`.
#[sqlx(skip)]
pub snapshot: RowSnapshot,
}
}
Apply the migration with cargo rullst db:migrate (or restart
cargo rullst dev) before using the model. The model leaves out
created_at/updated_at, so inserts keep their database defaults. If
cargo rullst dev already applied the migration while it had only an id,
run cargo rullst db:rollback (it reverts the last batch of migrations,
including this one) and then cargo rullst db:migrate;
Tutorial 05 explains both commands.
Step 2: Perform CRUD operations
Call these methods from an async handler or seeder, for example in a
controller of Tutorial 02.
Create
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
struct Product { id: i32, name: String, price_cents: i32, #[sqlx(skip)] snapshot: RowSnapshot }
async fn create_product() -> Result<(), rullst::orm::Error> {
let mut product = Product {
id: 0,
name: "Starter kit".to_string(),
price_cents: 2_500,
snapshot: RowSnapshot::default(),
};
product.save().await?;
// `product.id` now contains the inserted primary key.
Ok(())
}
}
Read and filter
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
struct Product { id: i32, name: String, price_cents: i32, #[sqlx(skip)] snapshot: RowSnapshot }
async fn read_products() -> Result<(), rullst::orm::Error> {
let product = Product::find(1).await?; // Result<Option<Product>, rullst::orm::Error>
let kits = Product::query()
.where_like("name", "%kit%")
.get()
.await?;
let _ = (product, kits);
Ok(())
}
}
Builder values are bound as query parameters. Column names are validated as identifiers, but they should still be application-owned constants rather than untrusted request input.
Update
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
struct Product { id: i32, name: String, price_cents: i32, #[sqlx(skip)] snapshot: RowSnapshot }
async fn update_product() -> Result<(), rullst::orm::Error> {
if let Some(mut product) = Product::find(1).await? {
product.price_cents = 2_000;
// The snapshot makes this an UPDATE of `price_cents` only.
product.save().await?;
}
Ok(())
}
}
Delete
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
struct Product { id: i32, name: String, price_cents: i32, #[sqlx(skip)] snapshot: RowSnapshot }
async fn delete_product() -> Result<(), rullst::orm::Error> {
if let Some(product) = Product::find(1).await? {
product.delete().await?;
}
Ok(())
}
}
Without a RowSnapshot field, save() on a loaded record writes every
column, so the last writer wins; see the
ORM guide for dirty
tracking and update_partial().
Soft deletes
A deleted_at: Option<String> field opts the model into the default soft-delete
contract. For a different sentinel, configure it explicitly and ensure the
migration uses the same representation:
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(
table = "users",
soft_delete(field = "is_deleted", value = "0", delval = "1")
)]
pub struct SoftUser {
pub id: i32,
pub name: String,
pub is_deleted: i32,
}
}
query() applies the model’s configured scopes. unscoped() is an explicit
administrative escape hatch and should not be used directly from request data.
restore() clears the soft-delete marker and force_delete() removes the row
permanently. Both check the tenant and the policy (can_restore /
can_force_delete) first and then run in a savepoint of the task-scoped
Orm::transaction, or in their own transaction, like delete():
force_delete()runs thebefore_delete/after_deletehooks and thedeleting/deletedobservers, writes aforce_deletedaudit entry on auditable models, and after commit invalidates the query cache, publishes the Redisdeletedevent, callscommitted(Deleted)and removes the Scout document. It does not cascade tocascade_soft_deleterelations.restore()re-reads the restored row, calls theupdatedandsavedobservers with it, writes arestoredaudit entry, and after commit invalidates the cache, publishes the Redisupdated/savedevents, callscommitted(Updated)and re-indexes the Scout document. The save hooks and thesaving/updatingobservers are not called, because restore writes only the soft-delete column. Restoring a missing or live row is a no-op, and deleting an already trashed row again fails without changing its deletion time.
Key takeaways
save,find,all,query, anddeleteare generated for SQLx-backedOrmmodels.- Creation uses a normal Rust struct, not a JSON map.
- CRUD errors are returned as
rullst::orm::Error(therullst_orm::Errortype); a missing row isOk(None). Saving or deleting a handle whose row was deleted meanwhile returnsRecordNotFoundinstead of silently succeeding. - Use a caller-owned transaction and the generated
*_with_txmethods when a business operation must commit multiple writes atomically.
Tutorial 04: Data Mapper & Repository Pattern 🏗️
For domain-heavy code, Rullst exposes a small Repository<T> contract. The
framework does not invent SQL for this trait: your adapter owns its pool,
queries, transactions, and error type. This keeps persistence behavior explicit
and makes an in-memory implementation straightforward in unit tests.
GenericRepository<T> is currently only a zero-state marker/helper. It does
not accept a pool and does not provide methods such as find_one_by.
Step 1: Implement a PostgreSQL repository
This User is a plain struct, not a #[derive(Orm)] model (which implements
FromRow itself), so it derives SQLx’s FromRow. Rullst does not enable
SQLx’s derive macros (since 13.0.0-alpha.4): add them to your manifest with
sqlx = { version = "0.9", default-features = false, features = ["derive"] },
or implement FromRow by hand.
#![allow(unused)]
fn main() {
use rullst_orm::{async_trait, sqlx, Repository};
#[derive(Debug, Clone, sqlx::FromRow)]
pub struct User {
pub id: i64,
pub name: String,
pub email: String,
}
#[derive(Clone)]
pub struct PgUserRepository {
pool: sqlx::PgPool,
}
impl PgUserRepository {
pub fn new(pool: sqlx::PgPool) -> Self {
Self { pool }
}
pub async fn find_by_email(
&self,
email: &str,
) -> Result<Option<User>, sqlx::Error> {
sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users WHERE email = $1",
)
.bind(email)
.fetch_optional(&self.pool)
.await
}
}
#[async_trait]
impl Repository<User> for PgUserRepository {
type Id = i64;
type Error = sqlx::Error;
async fn find_by_id(&self, id: i64) -> Result<Option<User>, Self::Error> {
sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users WHERE id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
}
async fn find_all(&self) -> Result<Vec<User>, Self::Error> {
sqlx::query_as::<_, User>(
"SELECT id, name, email FROM users ORDER BY id",
)
.fetch_all(&self.pool)
.await
}
async fn save(&self, user: &User) -> Result<(), Self::Error> {
sqlx::query(
"INSERT INTO users (id, name, email) VALUES ($1, $2, $3) \
ON CONFLICT (id) DO UPDATE SET name = $2, email = $3",
)
.bind(user.id)
.bind(&user.name)
.bind(&user.email)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete(&self, id: i64) -> Result<(), Self::Error> {
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
}
}
All values are parameterized. SQL identifiers and the query shape remain application-owned source code.
This adapter targets PostgreSQL (PgPool, $1 placeholders) and a users
table with BIGINT ids. Inside a generated project, rullst::db::Orm::pool()?
returns the application’s pool as RullstPool, which is sqlx::SqlitePool
with the SQLite starter and sqlx::PgPool with PostgreSQL: clone it into the
adapter and write the queries for that database.
Step 2: Inject the concrete adapter through Axum state
use std::sync::Arc;
use rullst::web::axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct EmailQuery {
email: String,
}
pub async fn get_user_by_email(
State(repository): State<Arc<PgUserRepository>>,
Query(query): Query<EmailQuery>,
) -> Result<Json<User>, StatusCode> {
repository
.find_by_email(&query.email)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
rullst::Router has no with_state: build this route on an axum::Router,
call .with_state(Arc::new(repository)) and mount it with merge_axum, as
Tutorial 08
shows. Returning Json<User> also requires User to derive
serde::Serialize.
Production handlers should map internal database errors to an application error
without returning query or credential details to clients.
Key takeaways
- Use derived Active Record models for direct typed CRUD.
- Use
Repository<T>when the domain needs an explicit persistence boundary. - The repository implementation, not the trait, determines the supported backend and SQL dialect.
- Add tenant/owner predicates inside repository queries where the resource is tenant- or user-scoped; dependency injection is not an authorization check.
Tutorial 05: Database Migrations & Seeders 🗄️
Rullst uses timestamped Rust migration modules for SQLx-primary projects. Turso
primary projects receive explicit, reversible TursoMigration statements
instead. This tutorial shows the SQLx path: Schema works with SQLite,
PostgreSQL and MySQL/MariaDB only, as Database Support
lists.
Step 1: Create a migration
Run this in the shop project of Tutorial 02, which
already has products and categories tables:
cargo rullst make:migration create_catalog_items_table
The command creates src/migrations/m<timestamp>_create_catalog_items_table.rs
and regenerates src/migrations/mod.rs. Edit the generated up and down
methods:
#![allow(unused)]
fn main() {
use rullst_orm::{async_trait, schema::{ForeignKeyAction, Migration, Schema}};
pub struct MigrationImpl;
#[async_trait]
impl Migration for MigrationImpl {
fn name(&self) -> &'static str {
"m20260901000000_create_catalog_items_table"
}
async fn up(&self) -> Result<(), rullst_orm::Error> {
Schema::create("catalog_items", |table| {
table.id();
table.string("name").not_null();
// MySQL/MariaDB cannot index a TEXT column, so the indexed SKU
// has a bounded type.
table.string("sku").not_null().unique().col_type = "VARCHAR(64)".to_string();
table
.foreign_id("category_id", "categories")
.on_delete(ForeignKeyAction::Restrict);
table.integer("price_cents").not_null();
table.check("price_cents >= 0");
table.date("available_on");
table.index(&["category_id", "available_on"]);
table.timestamps();
})
.await
}
async fn down(&self) -> Result<(), rullst_orm::Error> {
Schema::drop_if_exists("catalog_items").await
}
}
}
Keep the generated timestamp/name in name(); the runner uses that stable value
to record migration state. The foreign key needs the referenced table:
categories comes from cargo rullst make:resource Category, and this
migration, generated later, has a later timestamp and runs after it.
unique(), index(...), foreign_id(...)/foreign(...) and check(...)
declare constraints and indexes with the table (see the
ORM guide for every
option and the generated names). PostgreSQL and SQLite create the indexes with
CREATE INDEX IF NOT EXISTS right after the table; MySQL/MariaDB declare them
inside CREATE TABLE.
table.timestamps() creates nullable created_at/updated_at UTC-instant
columns that default to the current time: TIMESTAMPTZ on PostgreSQL,
DATETIME(6) on MySQL/MariaDB and RFC 3339 text on SQLite. Read them as
rullst::orm::chrono::DateTime<Utc> model fields. date(), time(),
timestamp() and timestamp_tz() add single date/time columns for
NaiveDate, NaiveTime, NaiveDateTime and DateTime<Utc> fields. They need
a strict driver feature (strict-postgres, strict-mysql or strict-sqlite),
which cargo rullst new selects: the multi-driver SQLx Any pool cannot read
date/time values, so with it timestamps() keeps TEXT columns for String
fields and the other date/time helpers fail on PostgreSQL and MySQL/MariaDB.
To change an existing table, use Schema::table:
#![allow(unused)]
fn main() {
use rullst_orm::schema::{ForeignKeyAction, Schema};
async fn up() -> Result<(), rullst_orm::Error> {
Schema::table("products", |table| {
table.integer("category_id");
table
.foreign("category_id")
.references("categories", "id")
.on_delete(ForeignKeyAction::SetNull);
table.unique_index(&["name", "category_id"]);
})
.await
}
}
SQLite cannot add constraints to an existing table: a foreign key works there
only on a nullable column added in the same call, and a CHECK, a foreign key on
an existing column or a NOT NULL column without a default return
Error::UnsupportedSchemaChange before anything runs.
table.float(...) maps to an f64 model field: it emits DOUBLE PRECISION on
PostgreSQL, DOUBLE on MySQL/MariaDB and REAL on SQLite. PostgreSQL columns
created by earlier Rullst versions were single-precision REAL; migrate them
explicitly (ALTER TABLE products ALTER COLUMN price TYPE DOUBLE PRECISION)
before relying on f64 precision.
table.boolean(...) maps to a bool model field. PostgreSQL receives a native
BOOLEAN (an integer default of 0/1 becomes FALSE/TRUE); MySQL/MariaDB
and SQLite keep an INTEGER 0/1 flag. PostgreSQL columns created by earlier
versions were INTEGER; migrate them with
ALTER TABLE t ALTER COLUMN c TYPE BOOLEAN USING c <> 0.
Text defaults (ColumnDefault::Text) and table.enum_col(...) variants are
embedded in the DDL as single-quoted literals with doubled single quotes.
Schema::create rejects such text when it contains a backslash or a control
character, because MySQL/MariaDB treat a backslash inside a quoted literal as an
escape character by default.
Step 2: Run, inspect, and roll back migrations
cargo rullst db:migrate
cargo rullst db:status
cargo rullst db:rollback
db:rollback runs down() for the last recorded batch, in reverse order. The
SQLx migration runner does not wrap the whole batch in one database
transaction. By default it runs each up()/down() and then records it in the
migrations table as separate statements, so a crash or lost connection in
between leaves a change applied but unrecorded. A migration that returns true
from within_transaction() (v13 prerelease API) runs up()/down() and its
record inside one Orm::transaction instead. The template written by the ORM’s
own artisan make:migration command opts in; add the method to migrations
generated by cargo rullst make:migration yourself:
fn within_transaction(&self) -> bool {
true
}
Only statements that join that transaction are covered: Schema calls,
generated model methods, rullst_orm::execute_query! and raw SQL sent with
Orm::raw(sql).execute() (or .map_to()). Orm::raw is the raw-SQL path that
stays in the transaction:
async fn up(&self) -> Result<(), rullst_orm::Error> {
Schema::create("tags", |table| {
table.id();
table.string("name").not_null();
})
.await?;
// Runs in the migration's transaction, so it sees the new table and rolls
// back with it.
Orm::raw("CREATE UNIQUE INDEX tags_name_key ON tags (name)")
.execute()
.await?;
Ok(())
}
Orm::pool() and Orm::read_pool() would hand out another connection that
cannot see the transaction’s uncommitted tables and does not roll back with
it. Since 13.0.0-alpha.3 they return Error::Validation, naming the migration,
while such a migration’s up()/down() runs, so the migration fails and rolls
back unrecorded instead of half-applying (Orm::begin_transaction() fails the
same way). Keep the default false for a migration that sends queries such as
sqlx::query(sql).execute(Orm::pool()?). A pool obtained before the migration
ran, another crate’s pool or a task spawned from up() is not detected and
still runs outside the transaction. PostgreSQL and SQLite roll the DDL back with the record; MySQL and
MariaDB commit each DDL statement implicitly, so there only data changes after
the last DDL statement are atomic with the record. Statements that refuse to
run in a transaction block, such as PostgreSQL CREATE INDEX CONCURRENTLY,
also need false. Make every migration reversible and test both directions
against each supported database.
These commands use the same database as the running server: the process
DATABASE_URL, then DATABASE_URL in ./.env (never overriding the process
environment), then [database].url in Rullst.toml. If none is set, the
command fails instead of creating a new SQLite file, and a database that cannot
be initialized also fails the command with exit status 1.
Each migration’s tracking row is removed as soon as its down() succeeds. If a
later down() fails, the rollback stops with that error: the migrations it
already reverted are no longer recorded as applied, while the failed migration
and the rest of the batch stay recorded. Fix the cause and run db:rollback
again to continue from that point.
db:migrate and db:rollback hold a runner lock for the whole run on
PostgreSQL (pg_advisory_lock) and MySQL/MariaDB (GET_LOCK), on a dedicated
connection outside the pool. Replicas that start together therefore apply each
pending migration once: a second runner waits, then finds it already recorded.
The lock is released when the run ends or its process exits. A newly created
migrations table also declares the migration name UNIQUE. SQLite has no
cross-process advisory lock, so run a single migrator per SQLite database.
Step 3: Define and register a seeder
Create src/seeds.rs with a seeder for the Product model of
Tutorial 03, and declare mod seeds; in
src/main.rs:
use rullst::db::{async_trait, RowSnapshot, Seeder};
use crate::models::product::Product;
pub struct CatalogSeeder;
#[async_trait]
impl Seeder for CatalogSeeder {
async fn run(&self) -> Result<(), rullst::orm::Error> {
// Idempotent: a second `db:seed` finds the row and adds nothing.
let existing = Product::query()
.where_eq("name", "Starter kit")
.first()
.await?;
if existing.is_some() {
return Ok(());
}
let mut product = Product {
id: 0,
name: "Starter kit".to_string(),
price_cents: 2_500,
snapshot: RowSnapshot::default(),
};
product.save().await
}
}
pub fn get_seeders() -> Vec<Box<dyn Seeder>> {
vec![Box::new(CatalogSeeder)]
}
Register migrations and seeders before starting the server: in main(),
replace the starter’s rullst::artisan!(crate::migrations::get_migrations());
with
rullst::artisan!(
crate::migrations::get_migrations(),
crate::seeds::get_seeders()
);
Without artisan!, Server::run still recognizes db:migrate, db:rollback,
db:status and db:seed, but it has no registry to run them against: the
process exits with status 1 and asks for rullst::artisan! rather than
reporting “Nothing to migrate.” as a success.
Then run:
cargo rullst db:migrate
cargo rullst db:seed
db:seed first checks the registered migrations against the migrations
table. While one is pending it runs no seeder and fails, instead of letting a
seeder fail on a table that does not exist yet:
Validation error: 1 migration(s) pending, starting with `m20261012_create_users`: run `db:migrate` before `db:seed`
Seeders execute sequentially. Make development/CI seeders idempotent, as above,
if the command may run more than once. Never commit real passwords or provider
secrets; for authentication records, hash a test password with rullst-auth or
create a non-login fixture.
Key takeaways
- Migrations are Rust modules, not split
up/downSQL files. - Migration names and order are generated deterministically from timestamps.
- Batch tracking exists, but batch-wide transactional rollback is not implied.
db:seedexecutes only seeders explicitly registered withartisan!, and only after every registered migration is applied.
Tutorial 06: HTMX-oriented server rendering 🎨
The Blank and SaaS starters render HTML on the server and use HTMX attributes
for targeted requests and fragment swaps. It does not require a project-local
SPA bundle, but HTMX itself is browser JavaScript. The CLI’s HTML starters write
a pinned copy to static/htmx-1.9.12.min.js, which rullst::htmx::render_page
loads from the same origin; an application without those generated assets must
serve that file itself. The application’s CSP must permit it.
🛠️ Step 1: Render HTMX Components
In your controller or view:
#![allow(unused)]
fn main() {
use rullst::html;
use rullst::html::Markup;
pub async fn search_users() -> Markup {
let results = vec!["Alice", "Bob", "Charlie"];
let rows: Markup = results
.into_iter()
.map(|name| html! {
<li class="py-2 text-slate-200">{name}</li>
})
.collect();
html! {
<ul id="user-list" class="divide-y divide-slate-700">
{rows}
</ul>
}
}
}
html! returns Markup, escaped HTML that a handler can return directly
(text/html; charset=utf-8). Each name is escaped, and the collected rows
are inserted into the list as HTML. Mount the handler in router() of
src/main.rs with get("/api/users/search" => search_users),.
Composing views
Interpolating a Markup inserts it as it is, so a view built by another
html! call nests without a wrapper. A String, &str or number is always
escaped, so request data stays text. Declare view helpers -> Markup, collect
lists with .collect::<Markup>() and use Option<Markup> for parts that may
be absent:
#![allow(unused)]
fn main() {
use rullst::html;
use rullst::html::Markup;
fn badge(label: &str) -> Markup {
html! { <span class="badge">{label}</span> }
}
let name = "<Ana>";
let flagged = true;
// `name` is escaped; the badge is inserted as HTML.
let row = html! { <li>{name}{badge("new")}</li> };
assert_eq!(row, "<li><Ana><span class=\"badge\">new</span></li>");
// `None` renders nothing.
let note = flagged.then(|| html! { <em>"flagged"</em> });
assert_eq!(html! { <p>{note}</p> }, "<p><em>flagged</em></p>");
}
rullst::html::RawHtml (or Markup::from_trusted) is only for trusted HTML
produced outside html!, such as a sanitizer’s output; never wrap request
data in it. A full page can start with <!DOCTYPE html>, which html! emits
as written, and element names may be Rust keywords, such as SVG’s
<use href="#icon" />.
💻 Step 2: Wire HTMX Attributes in Front-End HTML
<div class="max-w-md mx-auto p-6 bg-slate-800 rounded-xl shadow-md">
<input
type="text"
name="query"
placeholder="Search users..."
class="w-full px-4 py-2 bg-slate-900 text-white rounded border border-slate-700 focus:outline-none"
hx-get="/api/users/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#user-list"
hx-swap="outerHTML"
/>
<div id="user-list" class="mt-4 text-slate-400">
Start typing to search...
</div>
</div>
Put this markup in a page that loads HTMX: the starter’s home handler returns
its page through rullst::htmx::render_page_with_lang, which adds the
same-origin HTMX script. The search handler is a GET route because it only
reads a list. GET is not a workaround for CSRF. Commands that change data, send
messages or incur provider charges must not be converted to GET to avoid
validation.
For POST forms, including an AI chat, follow the
CSRF form contract.
HTMX does not automatically copy Rullst’s CSRF cookie into a header or make
HTTP error bodies visible in the target element.
🧪 Step 3: Snapshot-test rendered HTML
rullst::testing::assert_html_snapshot! (v13) compares rendered HTML with
tests/snapshots/<name>.html in the crate that calls it, in unit and
integration tests alike. Both sides are normalised first: whitespace-only text
between tags is dropped, every tag that follows another tag starts a new line,
other whitespace runs collapse to one space, and quoted attribute values and
the content of pre, textarea, script and style stay as written.
SnapshotOptions can also replace per-request values with documented
placeholders: mask_nonce() writes {NONCE} for nonce="…" attributes and
'nonce-…' CSP sources, and mask_csrf_token() writes {CSRF_TOKEN} for a
name="_token" field, <meta name="csrf-token"> and X-CSRF-Token values.
#![allow(unused)]
fn main() {
use rullst::html;
use rullst::testing::{SnapshotOptions, assert_html_snapshot};
fn search_form(nonce: &str, csrf_token: &str) -> rullst::html::Markup {
html! {
<form method="post" action="/search">
<input type="hidden" name="_token" value={csrf_token} />
<input type="search" name="q" />
<script nonce={nonce} src="/static/search.js"></script>
</form>
}
}
// In a #[test] function:
assert_html_snapshot!(
"search/form",
search_form("per-response-nonce", "per-request-token"),
SnapshotOptions::new().mask_nonce().mask_csrf_token()
);
}
A missing snapshot fails and names the file to create; a changed one fails
with a compact line diff (- snapshot, + rendered). Run the tests with
RULLST_UPDATE_SNAPSHOTS=1 to write missing snapshots and overwrite changed
ones, then review the files and commit them. Snapshot names use ASCII letters,
digits, _, - and ., with / between folders; a name that would leave
tests/snapshots is refused. The helper is test code: it panics like
assert_eq! and has no production use.
💡 Key Takeaways
- Small application-owned client surface: business logic can remain on the server while HTMX coordinates browser requests.
- Partial rendering: handlers can return fragments instead of full pages. Measure page weight and latency for the actual application; no fixed size or load-time guarantee follows from the rendering style.
Tutorial 07: Forms & DTO Validation 📝
Rullst provides ValidatedForm<T> and ValidatedJson<T> extractors. They parse
the request and run validator constraints before the handler is called.
Invalid payloads become bounded 400, 413, 415 or 422 JSON responses.
HTMX requests (HX-Request: true) instead receive an HTML error fragment with
200 OK, because HTMX 1.x and 2.x swap only successful responses by default;
the X-Rullst-Validation-Status header carries the REST status. htmx
swaps the fragment into the form’s hx-target with its hx-swap, so point
hx-target at an error or result container rather than at the form itself.
A body that cannot be parsed gets a fixed message: 413 “Request body is too
large.” for a body over the extractor limit, 415 “Unsupported request content
type.” for a wrong content type, and 400 “The submitted data could not be
read or has an invalid format.” for a malformed body or an unknown enum
variant (422 stays reserved for failed validator constraints). The
deserializer’s own text can echo request input, so it is logged at debug
level on the rullst::validation target and not returned. Every message and
field name in the HTMX fragment is HTML-escaped, including custom validator
messages. Errors inside #[validate(nested)] structs and lists are reported
under dotted and indexed paths such as address.zip and items[0].name.
ValidationError’s Display and Debug list only those paths and validator
codes, never the rejected values, so logging the error cannot leak a password.
Step 1: Define a validated DTO
#![allow(unused)]
fn main() {
use serde::Deserialize;
use rullst::Validate;
#[derive(Debug, Deserialize, Validate)]
pub struct CreateUserForm {
#[validate(length(min = 3, max = 100))]
pub name: String,
#[validate(email)]
pub email: String,
#[validate(length(min = 12, max = 72))]
pub password: String,
}
}
#[derive(Validate)] expands to ::validator::… paths, so the application
needs a direct validator dependency. Projects generated by the v13 CLI
already declare it; in an older project add the version Rullst uses:
validator = { version = "0.21", features = ["derive"] }
length counts characters, while password hashing refuses more than 72
bytes: a 72-character password with accented or non-Latin characters passes
this validator and then fails to hash. Check form.password.len() <= 72 (bytes)
in the handler as well, as the controller generated by cargo rullst auth does.
Add a request-body limit at the router/proxy boundary as validation happens after
body extraction.
Step 2: Validate and hash before persistence
The following handler assumes the User model (src/models/user.rs) that
cargo rullst auth generates in Tutorial 11:
use rullst::{html, server::StatusCode, Markup, ValidatedForm};
use rullst::auth::hash_password_async;
use rullst::db::RowSnapshot;
use rullst::orm::chrono::Utc;
pub async fn store(
ValidatedForm(form): ValidatedForm<CreateUserForm>,
) -> Result<Markup, StatusCode> {
// `length` counted characters; hashing refuses more than 72 bytes.
if form.password.len() > 72 {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
let password_hash = hash_password_async(form.password)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// The ORM inserts every field, so the model carries the `timestamps()`
// values instead of relying on the column defaults.
let now = Utc::now();
let mut user = crate::models::user::User {
id: 0,
name: form.name.trim().to_owned(),
email: form.email.trim().to_ascii_lowercase(),
password_hash: Some(password_hash),
oauth_provider: None,
oauth_id: None,
created_at: now,
updated_at: now,
// `#[orm(db_default)]` columns: inserts leave them to the database.
is_admin: false,
session_epoch: 0,
snapshot: RowSnapshot::default(),
};
user.save()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(html! {
<div class="p-4 bg-emerald-900/50 text-emerald-300 rounded border border-emerald-500">
<p>"User created successfully"</p>
</div>
})
}
This is a continuation snippet because the generated model lives in the
application crate. The controller generated by cargo rullst auth sets the
timestamps the same way with its utc_timestamp helper and also rejects a
duplicate email before inserting. The checked extractor implementation itself
is covered by rullst-core tests.
For a JSON endpoint, replace ValidatedForm with ValidatedJson; the DTO and
handler body remain the same.
Step 3: Send the CSRF token with browser forms
Production/staging Server requests use the security baseline, including
double-submit CSRF validation. A POST needs both the rullst_csrf cookie and
its matching value in a form _token field or X-CSRF-Token header.
Extract the request-scoped token on the page’s GET handler. On a visitor’s first request, this is the same token that the middleware will set in the response cookie; reading the incoming cookie alone would miss it.
#![allow(unused)]
fn main() {
use rullst::{html, security::CsrfToken, server::Extension, Markup};
pub async fn message_form(
Extension(csrf): Extension<CsrfToken>,
) -> Markup {
html! {
<form id="message-form" method="post" action="/messages"
hx-post="/messages" hx-target="#message-result" hx-swap="innerHTML">
<input type="hidden" name="_token" value={csrf.as_str()} />
<label for="message">"Message"</label>
<input id="message" name="message" required maxlength="600" />
<button type="submit">"Send"</button>
</form>
<div id="message-result" role="status" aria-live="polite"></div>
}
}
}
Mount GET and POST handlers in the protected application and serve the pinned
HTMX asset from the same origin. This is a form-rendering snippet, not a full
messaging service: /messages still needs a bounded validated DTO, application
authorization and the intended domain operation. The hidden field works with
both a normal URL-encoded form submission and HTMX; no global cookie-forwarding
script is required. JSON requests instead need the matching header, while the
browser still sends the cookie. Do not forward tokens to third-party URLs.
Serve form pages from ordinary paths. A GET for a path that looks like a
static file never creates the token or sets the cookie: /robots.txt,
/sitemap.xml, /favicon.ico, anything under /static/, and any path ending
in .txt, .xml, .ico, .json, .css, .js, .png, .jpg, .jpeg,
.svg, .webp or .wasm. Since 13.0.0-alpha.2 such a page still receives
CsrfToken when the browser already has a valid rullst_csrf cookie from
another page, but on a first visit there is none, and the
Extension<CsrfToken> extractor fails with 500.
The development server does not automatically apply the production CSRF/WAF
stack; its WAF only logs the requests production would refuse. For this
extractor in development, explicitly compose
rullst::server::from_fn(rullst::security::csrf_middleware) on the application
router, as the generated form-enabled starters do. Nested CSRF composition with
the production server is request-idempotent. Do not make the extractor optional
and silently render an empty token. Exercise the production baseline over HTTPS
before deployment; do not remove Secure to accommodate a production HTTP URL.
Show failures instead of a silent spinner
For the pinned HTMX 1.9.12 used by these examples, ordinary HTTP errors do not
automatically replace the target content (ValidatedForm failures are the
exception described above). Put the following in an
application-owned, same-origin external script loaded with defer. This avoids
requiring inline event handlers or weakening Content Security Policy:
const form = document.getElementById('message-form');
const status = document.getElementById('message-result');
if (form && status) {
const showFailure = (event) => {
if (event.detail.elt !== form) return;
status.textContent = 'Message not sent. Reload the page and try again.';
};
for (const name of ['htmx:responseError', 'htmx:sendError', 'htmx:timeout']) {
form.addEventListener(name, showFailure);
}
form.addEventListener('htmx:afterRequest', (event) => {
// Validation fragments are swapped with 200 and this header.
const rejected = event.detail.xhr.getResponseHeader('X-Rullst-Validation-Status');
if (event.detail.elt === form && event.detail.successful && !rejected) {
const input = form.elements.namedItem('message');
if (input instanceof HTMLInputElement) input.value = '';
}
});
}
Keep the message on failure so the visitor can retry. Never insert an arbitrary
server error body using innerHTML. Resetting only after success is also clearer
than relying on event-order assumptions. In HTMX 1.9.12, form values have already
been collected before htmx:beforeRequest; clearing an input there alone does
not establish that an empty message was transmitted.
Diagnose a 403 before changing security policy
| Response | Inspect |
|---|---|
CSRF token cookie missing | The POST’s Cookie header: the cookie may be absent, duplicated or malformed. Check that the preceding GET set it, that the browser accepted it, and that scheme/host/path and credential settings match. A hidden field cannot replace the cookie. |
Invalid or missing CSRF token | The form’s _token value and URL-encoded content type. Confirm the input belongs to the submitted form and matches the current cookie. |
Invalid CSRF token | The explicit header, which takes precedence over the form. Remove a stale custom header rather than expecting the valid form field to override it. |
Missing CSRF token: a multipart/form-data form must send its _token field before any file field … | A file-upload form whose hidden _token input comes after a file input (or after 64 KiB of other fields). Move _token to the top of the form or send X-CSRF-Token; see form uploads. |
Do not log token values or session cookies. HX-Request: true is not a security
exemption. Public browser chat endpoints retain CSRF and need separate abuse,
rate and cost limits. The existing csrf_signed_webhook_paths configuration is
only for exact POST paths authenticated by mandatory signed-webhook validation;
it is not an AI-chat allowlist and does not verify signatures by itself.
Core’s htmx_csrf_contract regression tests exercise first-visit cookie/form
agreement, ordinary and HTMX submissions, preservation of form data and
rejection before handler execution. These in-process HTTP tests do not certify
a reverse proxy, browser cookie policy, a live AI provider or application auth.
Key takeaways
- Validation is not sanitization and not authorization; enforce all three at their respective boundaries.
- Never store, log, or echo a plaintext password. Use the asynchronous Argon2id helper in request handlers.
- Enforce a unique database index on normalized email rather than relying only on a pre-insert lookup.
- Keep internal validation/database details out of production client errors.
Tutorial 08: Controllers, Routing & Middleware 🚦
Rullst’s Router wraps Axum’s router while preserving explicit Tower
middleware composition. This example uses the Axum 0.8 request and Next types.
Step 1: Create a custom middleware
Generate the file with the CLI:
cargo rullst make:middleware Logger
It writes src/middlewares/logger_middleware.rs with an empty
logger_middleware function and registers the module. Fill in the body, for
example:
#![allow(unused)]
fn main() {
use rullst::web::axum::{
extract::Request,
middleware::Next,
response::Response,
};
pub async fn logger_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_owned();
let response = next.run(req).await;
// Prefer structured tracing in production; do not log query strings,
// cookies, authorization headers, or request bodies by default.
println!("[HTTP] {method} {path} -> {}", response.status());
response
}
}
Step 2: Organize sub-routers
use rullst::{Router, Server, ServerError};
use rullst::routing::{get, post};
use rullst::web::axum::middleware;
async fn list_users() -> &'static str { "users" }
async fn create_user() -> &'static str { "created" }
async fn login() -> &'static str { "login" }
// Stand-in for the generated `logger_middleware` of Step 1.
async fn logger_middleware(
request: rullst::web::axum::extract::Request,
next: rullst::web::axum::middleware::Next,
) -> rullst::web::axum::response::Response {
next.run(request).await
}
#[tokio::main]
async fn main() -> Result<(), ServerError> {
let api = Router::new()
.route("/users", get(list_users).post(create_user))
.layer(middleware::from_fn(logger_middleware));
let auth = Router::new().route("/login", post(login));
let app = Router::new()
.nest("/api/v1", api)
.nest("/auth", auth);
Server::new(app).run(3000).await
}
In the application, import the generated function
(use crate::middlewares::logger_middleware::logger_middleware;) and pass it to
from_fn the same way. A layer covers only the routes added before it. Use
nest_axum or merge_axum when integrating a third-party raw
axum::Router.
Step 3: Share application state
rullst::Router wraps an axum::Router<()>, whose handlers cannot take
State<S>, so it has no with_state. Pass shared state in one of two ways:
- As a request extension.
.layer(Extension(state))inserts the value into every request, and handlers read it withExtension<T>. Rullst’s own middleware passes the CSRF token, the CSP nonce and the signed-in user id this way. Like every layer, it covers only the routes added before it. - With Axum’s
State. Build the routes that useState<S>as anaxum::Router<S>, call itswith_state, and mount the result withmerge_axum(ornest_axumunder a prefix).
use rullst::{Router, Server, ServerError};
use rullst::routing::get;
use rullst::web::axum::{self, Extension, extract::State};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
site_name: Arc<str>,
}
async fn home(Extension(state): Extension<AppState>) -> String {
format!("Welcome to {}", state.site_name)
}
async fn about(State(state): State<AppState>) -> String {
format!("About {}", state.site_name)
}
#[tokio::main]
async fn main() -> Result<(), ServerError> {
let state = AppState { site_name: Arc::from("Rullst") };
let stateful = axum::Router::new()
.route("/about", get(about))
.with_state(state.clone());
let app = Router::new()
.route("/", get(home))
.layer(Extension(state))
.merge_axum(stateful);
Server::new(app).run(3000).await
}
Keep values in the state cheap to clone (Arc, connection pools): each request
clones it.
Key takeaways
- Middleware order is security-sensitive. Use the canonical production baseline for secure headers, CSRF, CORS, and WAF rather than assembling those controls ad hoc.
- Authentication middleware establishes identity; handlers/repositories must still enforce resource ownership or role authorization.
- Apply request-body and concurrency limits before handlers that parse expensive or attacker-controlled input.
Tutorial 09: Environment Management & Configuration ⚙️
Rullst provides a validated runtime environment shared by its subsystems. New
projects use RULLST_ENV; APP_ENV remains a legacy compatibility alias.
🛠️ Step 1: Define Environment Variables in .env
cargo rullst new already writes .env with RULLST_ENV=development, a random
APP_KEY and the starter’s DATABASE_URL, plus a .env.example with a
placeholder key. A hand-written project adds the file itself:
RULLST_ENV=development
PORT=3000
APP_KEY=REPLACE_WITH_YOUR_32_CHAR_RANDOM_KEY
DATABASE_URL=postgres://postgres:password@localhost:5432/my_app
Replace the placeholder with at least 32 bytes of random data, for example the
output of openssl rand -base64 32: rullst-auth, which encrypts session
cookies with APP_KEY, refuses shorter keys and the documented placeholders.
PORT overrides the port passed to Server::run.
💻 Step 2: Access Configuration in Rust
#![allow(unused)]
fn main() {
use rullst::config::RullstConfig;
fn report_environment() -> Result<(), Box<dyn std::error::Error>> {
let environment = RullstConfig::global().environment()?;
// The process environment first, then `./.env`, as `Server` reads it.
let database_is_configured =
rullst::config::project_setting("DATABASE_URL")?.is_some();
println!(
"Booting Rullst in {environment} mode; database configured: {database_is_configured}"
);
Ok(())
}
}
Do not log DATABASE_URL: it commonly embeds a username and password. Report
only whether configuration is present, and redact sensitive fields in structured
telemetry.
The generated server bootstrap reads .env before applying its runtime policy.
Neither Server nor the Artisan commands (db:migrate, db:seed, studio)
load .env into the process environment, so std::env::var sees only real
process variables there; read application settings with
rullst::config::project_setting, which checks the process environment and
then ./.env, and behaves the same in the server and in a seeder. Standalone
utilities must load a dotenv file themselves or receive exported
process variables. Environment precedence is exact: the process RULLST_ENV,
legacy APP_ENV, then RULLST_ENV or APP_ENV in .env, then [app].env in
Rullst.toml. Server never exports .env into the process environment;
RullstConfig::environment() includes the .env step once a Server has
started in the process, so it reports the environment the server enforces.
Before that, and in standalone utilities, it reads only the process variables
and [app].env, as Environment::detect always does. Unknown values are configuration
errors rather than silently becoming development. rullst-auth cookie helpers
also consult .env below the process variables and skip reading it when either
is set; a malformed .env yields a fixed error that never quotes file content.
A malformed or mistyped Rullst.toml fails with only its line and column, so
the error can be logged without exposing app_key or connection strings.
Step 3: Configure the Browser Security Baseline
Server applies the same public apply_security_baseline composition in
staging and production. CORS is deny-by-omission: list exact origins without a
trailing slash, and enable credentialed cross-origin requests only when the
application genuinely needs them.
[security]
csrf_same_site = "Strict"
cors_allow_origins = ["https://academy.example"]
cors_allow_credentials = false
csp = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'nonce-{NONCE}'; style-src 'self' 'nonce-{NONCE}'"
coep = "require-corp"
Wildcard, path-bearing, credential-bearing, queried or duplicate CORS origins
are configuration errors, as are spellings a browser never sends: an uppercase
scheme or host, or an explicit default port (:443 for HTTPS, :80 for HTTP).
CORS compares the configured origin with the Origin header byte for byte. When credentials are enabled, Core still grants them
only to an origin in the exact allowlist. Test the final policy behind the real
TLS proxy because an intermediary can change headers and cookie behavior.
coep accepts only require-corp, credentialless, or unsafe-none.
Keep the strict require-corp default for same-origin assets. If an application
must load reviewed cross-origin media whose server does not emit CORP, it can
explicitly select credentialless and update the relevant CSP source directive;
the browser then omits credentials from those cross-origin no-CORS requests.
unsafe-none lowers cross-origin isolation and should be chosen only after an
application threat-model review. COEP configuration never makes an arbitrary
remote origin trustworthy.
The same baseline adds Cache-Control: no-store to every response that has no
Cache-Control header of its own, so pages with personal data are not stored
by browsers or shared caches. Responses that should be cached, such as file
downloads or calendar and RSS feeds, must set their own header; the standard
/static mount already does.
💡 Key Takeaways
- Never hardcode credentials, database passwords, or JWT secrets in
.rssource code. - Add
.envto.gitignoreand commit.env.exampleto document required keys for team members. - Prefer
RULLST_ENV; keepAPP_ENVonly while migrating an existing project.
Tutorial 10: Static Assets & Pre-Compression 📦
The standard Server serves an existing static/ directory at /static.
Production builds can create Brotli and Zstandard sidecars for eligible text and
Wasm assets in that directory.
Step 1: Use the standard static directory
static/
├── css/
│ └── app.css
├── js/
│ └── app.js
└── favicon.svg
Reference those files through /static/..., for example
/static/css/app.css. Server::run mounts the directory when it exists; no
additional ServeDir layer is required for this standard path.
use rullst::{routes, routing::get, Server};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = routes![get("/" => || async { "Rullst" })];
Server::new(app).run(3000).await?;
Ok(())
}
If you mount a different directory manually through Axum/Tower, its routing and pre-compressed negotiation become application responsibilities.
Step 2: Build production sidecars
cargo rullst build
The release-mode command builds the application and creates .br and .zst
siblings for html, css, js, json, svg, wasm, xml, and txt files
under static/. The standard server negotiates Brotli through ServeDir and
Zstandard through its static middleware.
The server serves an existing sibling without comparing it with its source, so
an asset edited after the last build would keep reaching browsers that accept
br or zstd in its old form. cargo rullst dev therefore deletes every
.br/.zst sibling that is not newer than its source when it starts and before
each rebuild, and reports how many it removed. A sibling without a source file
is kept, because it may be hand-made. Outside cargo rullst dev, rerun
cargo rullst build after editing assets and before docker build copies
static/ into an image.
The Zstandard middleware serves name.zst only when Accept-Encoding lists
zstd with a quality above zero (zstd;q=0 is a refusal) and the path below
/static/ has only plain segments. Paths with ., .., empty segments,
backslashes or percent-encoding skip the .zst lookup and go to ServeDir
uncompressed. Content-Encoding: zstd is added only to 2xx and 304
responses. A hand-made .zst is used only for a type the middleware can label
(the generated types plus .htm, .mjs, .cjs, .map, .webmanifest,
.csv, .pdf, .ttf and .otf); for any other file the uncompressed asset
is served, never a .zst body typed application/octet-stream.
Verify deployed behavior rather than assuming negotiation worked:
curl --compressed -I -H 'Accept-Encoding: br' \
http://127.0.0.1:3000/static/css/app.css
curl -I -H 'Accept-Encoding: zstd' \
http://127.0.0.1:3000/static/css/app.css
Check Content-Encoding, Content-Type, cache headers, and Vary through the
actual TLS proxy/CDN. Pre-compression avoids compression work per request; it
does not eliminate file I/O or network latency.
Step 3: Cache headers
Since v13 the standard static mount sets Cache-Control itself (Rullst does
not rename files; your asset tool does):
Request path below /static/ | Cache-Control |
|---|---|
Fingerprinted name: a .- or --separated segment of 8–64 lowercase hex characters, with at least one digit and one letter, after the first part and before the extension (app.3f9a2c1b.css, chunk-3f9a2c1b.js, app.3f9a2c1b.js.map) | public, max-age=31536000, immutable |
Any other file (app.css, report-20261008.pdf) | no-cache |
no-cache lets browsers and caches store the file but revalidate it before
each use. ServeDir sends ETag and Last-Modified and answers a matching
If-None-Match or If-Modified-Since with 304 Not Modified and no body.
The policy is set on 2xx and 304 responses only, for every encoding
(br, zstd or none). A missing file and every dynamic response keep the
production security baseline’s Cache-Control: no-store.
An immutable file is never asked for again until it expires, so give an asset a new hashed name whenever its content changes, and do not hand-name a mutable file with a hash-like segment. Check the headers through your proxy or CDN:
curl -sI http://127.0.0.1:3000/static/css/app.css | grep -i -E 'cache-control|etag'
curl -sI -H 'If-None-Match: "<etag from above>"' \
http://127.0.0.1:3000/static/css/app.css # HTTP/1.1 304 Not Modified
Downloads and feeds
Dynamic responses keep Cache-Control: no-store unless the route chooses a
policy. Since 13.0.0-alpha.2, rullst::cache_control labels the 2xx and
304 responses of a route that do not already have a Cache-Control; errors
keep no-store:
#![allow(unused)]
fn main() {
use rullst::{Router, cache_control, routing::get};
use std::time::Duration;
async fn calendar_feed() -> &'static str { "BEGIN:VCALENDAR" }
async fn latest_report() -> &'static str { "report" }
let app = Router::new()
// The same for every visitor: browsers and CDNs may keep it 5 minutes.
.route(
"/calendar.ics",
get(calendar_feed).layer(cache_control::public(Duration::from_secs(300))),
)
// A user's own download: only their browser may keep it for an hour.
.route(
"/reports/latest",
get(latest_report).layer(cache_control::private(Duration::from_secs(3600))),
);
}
A handler can also return the policy with one response:
(CacheControl::private(Duration::from_secs(60)), body). Use public only
when the response is the same for every visitor, because a shared cache
serves one visitor’s copy to the next. The CSRF middleware never sets its
rullst_csrf cookie on a response that shared caches may store (public or
s-maxage without private or no-store), so a feed fetched without cookies
stays cacheable and never hands out a visitor’s token.
Key takeaways
- Use
static/for the framework’s standard asset path and build integration. - Keep source files alongside generated sidecars in the deployed artifact.
- Content-hashed names are cached for a year as
immutable; other static files are revalidated withETag/Last-Modified.
Tutorial 11: Authentication Scaffolding 🔒
The authentication generator creates a reviewable starting point for cookie-session login and registration. It is application source code, not a hosted identity service or a claim that every production policy is automatic.
Step 1: Run the generator
The SaaS starter already contains this code: there cargo rullst auth
reports that authentication is already scaffolded, writes nothing and exits
successfully. In a Blank or API project with a database (SQLite, PostgreSQL,
MySQL or MariaDB), such as the shop project of
Tutorial 02, run from the project root:
cargo rullst auth
Those starters ship a minimal users table (id, name, created_at and
updated_at, from src/migrations/m20260601000000_create_users_table.rs).
The command keeps that migration as it is and adds one that extends the
table, so it works whether or not cargo rullst db:migrate (or the first
cargo rullst dev) already created the table, and rows already in it keep
their data. Each existing row gets the placeholder email
user-<id>@no-email.invalid (the reserved .invalid domain never receives
mail) and no password, so it cannot sign in until you set its real email and
the person resets the password.
The current command creates:
src/models/user.rsandsrc/models/password_reset.rs;src/migrations/m<timestamp>_create_users_table.rsor, in a Blank or API starter,src/migrations/m<timestamp>_add_account_columns_to_users_table.rs(theemail,password_hash,oauth_provider,oauth_id,is_adminandsession_epochcolumns and the unique index onemail; rolling it back drops only those),src/migrations/m<timestamp>_create_password_reset_tokens_table.rsand the migrations module;src/controllers/auth_controller.rsandsrc/controllers/password_controller.rs;src/middlewares/auth_middleware.rs;src/pages/auth.rs;- the corresponding module declarations, including
controllers,middlewares,modelsandpagesinsrc/lib.rs(orsrc/main.rs); - in a Blank or API starter,
pub use crate::models::user::User;in place of the starter’s two-fieldUserstruct insrc/main.rs, so the project has one model ofusers(aUserstruct you changed is left alone); and - a best-effort refresh of the generated
.llms.txt,.rullst/context-map.jsonanddiagram.md(a hand-writtendiagram.mdis left unchanged).
It also enables the orm, auth and mail umbrella features in
Cargo.toml. When every one of those files already exists, as in the SaaS
starter or after an earlier run, it writes nothing and succeeds. It fails
before writing anything when only some of them exist, when a
*_create_password_reset_tokens_table.rs or
*_add_account_columns_to_users_table.rs migration exists, or when a
*_create_users.rs/*_create_users_table.rs migration other than the
unchanged starter one creates the users table: then add the columns with
cargo rullst make:migration and integrate the account files by hand, as the
error message says. Turso-primary projects are not
supported, and neither is a project without a database migration runner (a
--no-database starter): create the project with a database so src/main.rs
declares pub mod migrations; and calls
rullst::artisan!(crate::migrations::get_migrations()). It does not support an
auth --api flag, and it does not silently register application routes. Review
the generated diff before editing it.
Step 2: Register the generated handlers
Wire the generated view, submit, logout, and authenticated routes in the
application router. The exact route tree is an application decision; a typical
mapping is below. Put its use lines at the top of src/main.rs, which
already imports routes, and the let statements in router():
use rullst::{routing::post, server::from_fn};
use crate::controllers::auth_controller;
use crate::middlewares::auth_middleware::auth_middleware;
let public_auth = routes![
get("/login" => auth_controller::login_view),
get("/register" => auth_controller::register_view),
post("/logout" => auth_controller::logout),
]
// Each credential submission runs Argon2id: budget it per client.
.route("/login", post(auth_controller::login_submit)
.layer(from_fn(auth_controller::credential_rate_limit)))
.route("/register", post(auth_controller::register_submit)
.layer(from_fn(auth_controller::credential_rate_limit)));
let signed_in = routes![
get("/dashboard" => auth_controller::dashboard),
// Ends the session on every device.
post("/logout/everywhere" => auth_controller::logout_everywhere),
]
.layer(from_fn(auth_middleware));
Mount the password flows the same way: the reset routes are public, the change
routes need auth_middleware, and every submission is rate limited.
use crate::controllers::password_controller;
let password_reset = routes![
get("/password/forgot" => password_controller::forgot_password_view),
get("/password/reset" => password_controller::reset_password_view),
]
// Reset requests send mail and resets run Argon2id.
.route("/password/forgot", post(password_controller::forgot_password_submit)
.layer(from_fn(auth_controller::credential_rate_limit)))
.route("/password/reset", post(password_controller::reset_password_submit)
.layer(from_fn(auth_controller::credential_rate_limit)));
let password_change = routes![
get("/password/change" => password_controller::change_password_view),
]
.route("/password/change", post(password_controller::change_password_submit)
.layer(from_fn(auth_controller::credential_rate_limit)))
.layer(from_fn(auth_middleware));
Add each group to the starter’s router in router() (src/main.rs) with
.merge_axum(public_auth.into_axum()) and so on, before its
.layer(rullst::server::from_fn(rullst::security::csrf_middleware)) line so
that CSRF protection covers them. The SaaS starter’s router() shows the same
routes registered one by one.
credential_rate_limit allows ten submissions per minute for each connecting
peer. Behind a reverse proxy, configure [security] trusted_proxies so clients
do not share the proxy’s budget.
Reset emails go through the Mail facade: set MAIL_DRIVER and MAIL_FROM
for staging and production, and RULLST_PUBLIC_ORIGIN to the application’s
HTTPS origin (for example https://app.example.com), which every reset link
uses. Links are never built from the request’s Host header. Without
RULLST_PUBLIC_ORIGIN, development links point to http://localhost:3000,
and staging and production log an error instead of sending a link. In
development the default mail driver logs only metadata, so a debug build
without MAIL_DRIVER also prints the reset link to the terminal.
The emails name your product APP_NAME (process environment, then .env)
or, when it is unset, the APPLICATION_NAME constant at the top of
password_controller.rs, which starts as the package name. They are written
in the request’s language: the locale LOCALES.layer() negotiated when the
application uses rullst::i18n, otherwise the browser’s Accept-Language,
matched by language to English, Brazilian Portuguese or Spanish, otherwise
DEFAULT_MAIL_LOCALE (English), which you can edit there too.
The generated form pages expect the Core CSRF and CSP-nonce extensions. Apply the canonical security baseline and place authenticated routes behind the generated authentication middleware.
Step 3: Migrate and test
cargo rullst db:migrate
cargo rullst dev
Before production, exercise registration, login, logout, sign-out everywhere,
password change and reset, invalid credentials, duplicate email,
expired/tampered/revoked cookies and reset links, CSRF failure, rate limiting,
mail delivery from your provider, and key rotation. Configure a strong APP_KEY through a secret manager and HTTPS at the
edge.
What the scaffold currently enforces
- Passwords are hashed with Argon2id on Tokio’s blocking pool; plaintext is not
written to the user model. At most four hashes or verifications run at once
(each holds about 19 MiB); a submission that waits two seconds without
capacity receives 503. Adjust
MAX_CONCURRENT_PASSWORD_WORKto the host. - Registration accepts passwords of at least 12 characters and at most 72 bytes, and normalizes email.
- Login performs a dummy password verification for unknown users to reduce the obvious account-enumeration timing difference.
- Session values use authenticated encryption and are emitted as cookie headers
through
rullst-authhelpers. - The
users.session_epochcolumn (0 for a new account) goes into every login cookie, andauth_middlewarerefuses a cookie whose epoch differs.User::revoke_sessionsincrements it in oneUPDATE, ending every cookie of the account; the Sign out everywhere button (logout_everywhere) calls it. Plain logout clears only the current browser’s cookie. CallUser::revoke_sessionswhen you deactivate an account. See encrypted sessions. - Change password (
/password/change, signed in) asks for the current password, the new one and a confirmation. A wrong current password gets the generic “The current password is incorrect”. The new password follows the registration policy.User::change_passwordstores the new hash and increments the session epoch in oneUPDATEthat only applies while the account is still at the epoch of the asking cookie; that browser receives a cookie with the new epoch and every other device is signed out. A reset link requested before the change stops working. - Forgot password (
/password/forgot) answers with the same page, after at least 400 ms, whether or not the email has an account. For an account it creates aPasswordResetToken(32 random bytes from the operating system), stores only its SHA-256 digest inpassword_reset_tokenswith a 30-minute expiry, and emails the link in the background. The table keeps one token per account: a new request replaces the previous token, but requests for the same account less than 60 seconds apart send nothing. - Reset link lifetime:
RESET_TOKEN_LIFETIME_MINUTESinsrc/models/password_reset.rs(30 as generated) sets it. Change it there: a compile-time assertion keeps it from 10 minutes to 24 hours (1440), and the stored expiry, the email (“This link expires in 30 minutes.”, or “2 hours” for whole hours) and the forgot-password answer all follow it. Below 10 minutes, mail that a provider greylists or queues for a few minutes arrives after its link expired, and the user can only ask again. Above 24 hours, the link stays a working way into the account for as long as it sits in a mailbox that is synchronized to several devices, forwarded or read by someone else, while a day already covers a user who reads mail once a day. Single use and replacement by a newer request narrow that exposure but do not remove it, which is why the OWASP Forgot Password Cheat Sheet asks for short-lived tokens. - Reset password (
/password/reset?token=…) shows the form only for a live token, without caching and withReferrer-Policy: no-referrer. Submitting it deletes the token, stores the new hash and increments the session epoch in one transaction, so the link works once and every device is signed out. The user is not signed in: the page redirects to/login?reset=done. Both flows email a “password changed” notice.
These controls do not replace application review. Add account verification, abuse controls, audit policy, MFA/passkeys, per-device session management, and privacy/retention behavior according to the product’s threat model. A reset link proves only access to the mailbox; for high-value accounts, add a second factor to recovery.
Tutorial 12: JWT and Cookie Sessions 🔑
Cookie sessions and bearer JWTs are transport choices with different operational trade-offs. Neither is automatically “for web” or “for mobile,” and neither removes the need for TLS, authorization, rotation, expiry, and revocation design.
Step 1: Generate JWT middleware
cargo rullst make:jwt
This creates src/middlewares/jwt_middleware.rs, registers its module, and adds
the direct jsonwebtoken, chrono, and serde dependencies when missing.
Configure a high-entropy secret plus exact issuer and audience values:
export JWT_SECRET="$(openssl rand -base64 48)"
export JWT_ISSUER="https://identity.example.test"
export JWT_AUDIENCE="rullst-api"
The generated HS256 validator requires sub, iss, aud, iat, and exp,
checks expiry, and rejects secrets shorter than 32 bytes, with fewer than 12
distinct characters, or starting with mock_. It reads these variables with
std::env::var on each request, so they must be set in the process
environment: a value only in .env is not seen, and while one is missing a
request that carries a bearer token receives 500. Keep the secret out of
source control and logs.
The same file provides generate_token(user_id), which issues a token with
these settings that expires after 24 hours. Call it after your own credential
check, never for an unauthenticated caller.
Step 2: Protect a route group
use rullst::{Router, routing::get};
use rullst::web::axum::middleware;
use crate::middlewares::jwt_middleware::jwt_middleware;
pub fn protected_routes() -> Router {
Router::new()
.route("/profile", get(user_profile))
.route("/orders", get(user_orders))
.layer(middleware::from_fn(jwt_middleware))
}
Valid claims are inserted into request extensions. The handler must still map
sub to a current account and enforce resource/tenant authorization. For
long-lived systems, design key rotation and immediate revocation rather than
assuming expiry alone is sufficient.
Choosing deliberately
- An
HttpOnly,Secure, appropriatelySameSitecookie reduces direct token reads by browser JavaScript. It does not neutralize XSS: injected code can still act as the user, and cookie requests need CSRF protection. - A bearer token is convenient for interoperable API clients, but storage in a browser or native app is an application security decision. A stolen bearer token can be replayed until rejected or expired.
- Cookie and JWT strategies can coexist at different boundaries, but keep one authoritative identity/session lifecycle and test logout/revocation behavior.
Tutorial 13: RBAC, Ownership, and IDOR/BOLA Protection 🛡️
rullst-security provides role, owner, and tenant checks over a trusted
UserContext. The framework cannot infer ownership from a route parameter: the
application must load the resource and pass its stored owner/tenant identifiers
to the guard.
Step 1: Authorize a trusted user context
#![allow(unused)]
fn main() {
use rullst_security::{RbacGuard, SecurityError, UserContext};
pub fn authorize_admin(user: &UserContext) -> Result<(), SecurityError> {
RbacGuard::authorize(user, "admin")
}
}
Construct UserContext only after authentication. Roles, permissions, and
tenant membership must come from trusted server-side state, not request headers
or JSON supplied by the caller. In a project with the
cargo rullst auth scaffold, auth_middleware
inserts the signed-in account id as Extension<i32>; build the context with
UserContext::new(user_id.to_string(), roles) from roles your application
stores. Generated projects already depend on rullst-security.
Step 2: Check the resource record, not request ownership
#![allow(unused)]
fn main() {
use rullst_security::{RbacGuard, SecurityError, UserContext};
pub struct DocumentAccess {
pub owner_user_id: String,
pub tenant_id: String,
}
pub fn authorize_document_update(
user: &UserContext,
stored: &DocumentAccess,
) -> Result<(), SecurityError> {
RbacGuard::authorize_tenant_owner_or_role(
user,
&stored.tenant_id,
&stored.owner_user_id,
"document-editor",
)
}
}
Load DocumentAccess with a parameterized query by the route’s document ID. Do
not accept owner_user_id or tenant_id from the update payload. The tenant
guard is evaluated first and roles — including admin — do not bypass a tenant
mismatch.
For particularly sensitive paths, make the database query itself tenant-scoped and then apply the guard as a second boundary. Return the same not-found/forbidden shape where revealing resource existence would leak information.
Key takeaways
authorizechecks a role (and recognizes the framework’sadminrole).authorize_owner_or_roleis safe only when the owner ID came from trusted resource state.- Use
authorize_tenant_owner_or_rolefor tenant-bound resources. - A helper contributes to IDOR/BOLA prevention only when every parameterized resource route invokes it or an equivalent scoped repository policy.
Tutorial 14: RASP — Runtime Application Self-Protection ⚡
rullst-security::rasp applies bounded heuristic signatures to request targets,
non-secret headers, and supported textual bodies. Its current signatures cover
common SQL injection, path traversal, SSRF, shell/RCE, and JNDI indicators. It
does not claim general exploit detection. The powershell keyword is not
matched against the stock PowerShell product token in User-Agent
(PowerShell/7.4.1, WindowsPowerShell/5.1…), so Invoke-WebRequest and
Invoke-RestMethod clients are served, while execution syntax such as
powershell -enc or powershell.exe in that header still blocks.
🛠️ Step 1: Mount RaspSecurityLayer in main.rs
use rullst::web::axum::Router;
use rullst_security::rasp::RaspSecurityLayer;
use rullst::Server;
#[tokio::main]
async fn main() -> Result<(), rullst::ServerError> {
let app = Router::new()
// ... routes
.layer(RaspSecurityLayer::default());
Server::new(app.into()).run(3000).await
}
🧪 Step 2: Test Malicious Attack Payload Interception
Send a percent-encoded UNION SELECT payload in the query string:
curl -i "http://localhost:3000/api/users?query=1%27%20UNION%20SELECT%20password%20FROM%20users"
For a recognized bounded signature, the layer returns 403 Forbidden and adds
a process-local event to SecurityStore. A Studio instance running in the same
process can display that event at
http://127.0.0.1:5555/studio/security.
The body inspector accepts identity-encoded UTF-8 text, JSON, form, and XML media types up to 1 MiB. It fails closed for oversized declared bodies and encoded textual bodies that it cannot inspect. Put an independent request-body limit outside this layer as well.
🚧 What RASP does not inspect
CookieandAuthorizationheaders,multipart/form-datauploads and binary bodies.- Payloads hidden by double percent-encoding, HTML entities, SQL comments between keywords, alternative syntax or splitting across several fields.
- Anything without a listed signature.
It also refuses ordinary text that contains a signature, such as a forum post
that quotes ../ or /bin/sh. Test your own forms and API payloads with the
layer mounted.
In staging and production, Server already runs the Core WAF, which overlaps
with RASP and buffers the same body. See
which security layer to use, and when
before adding both.
💡 Key Takeaways
- Inspection has runtime cost and uses bounded pattern heuristics, with possible false positives and false negatives.
- RASP is defense in depth; parameterized SQL, validation, authorization, body limits, and dependency review remain required.
Tutorial 15: Vault and ORM field encryption
Rullst can encrypt String and Option<String> model fields before they
reach the database and decrypt them when a generated ORM query loads the model.
The implementation uses AES-256-GCM with a fresh random nonce and an
authenticated, versioned envelope.
1. Configure a current key
Generate 32 random bytes and store them as a prefixed base64 value. Do not commit this value to source control:
export RULLST_ENCRYPTION_KEY="base64:$(openssl rand -base64 32)"
export RULLST_ENCRYPTION_KEY_ID="production-2026-01"
RULLST_ENCRYPTION_KEY accepts base64:<value>, hex:<value>, or a legacy
raw value containing exactly 32 UTF-8 bytes. A secret manager or KMS-backed
deployment adapter should inject it at runtime. Rullst does not provide key
custody.
RULLST_ENCRYPTION_KEY, RULLST_ENCRYPTION_KEY_ID and
RULLST_ENCRYPTION_KEYRING are each read from the process environment first
and, when the process does not set them, from .env in the current working
directory, the precedence APP_KEY and rullst::config::project_setting use.
The file is read on each lookup and never loaded into the process
environment. In development a line such as
RULLST_ENCRYPTION_KEY=base64:... in the project’s .env is therefore
enough; keep .env out of version control and inject the key through the
environment in production.
2. Mark model fields
#![allow(unused)]
fn main() {
#[derive(Clone, Debug, rullst_orm::Orm)]
#[orm(table = "users")]
pub struct User {
pub id: i32,
pub email: String,
#[orm(encrypted)]
pub tax_id: String,
#[orm(encrypted)]
pub recovery_note: Option<String>,
}
}
Generated save, update_partial, find, all, query-builder loads and
streams preserve plaintext in the Rust model while storing an envelope shaped
like this in the SQL column:
RULLST:v2:<key_id>:<base64url_nonce>:<base64url_ciphertext_and_tag>
The table and column names are authenticated as additional data. Copying a ciphertext into a different annotated column therefore fails decryption.
Generated secondary copies do not undo that protection. to_json(), audit
rows and committed-event payloads (including Redis orm:events:* messages)
carry the fixed marker "***" instead of the value, and Scout documents omit
the field. save_to_redis writes the same authenticated envelope as the SQL
column and get_from_redis decrypts it, while the .remember(...) query cache
stores the ciphertext read from the database. Audit rows, indexes and Redis
hashes written before this behavior may contain plaintext: purge, reindex or
rewrite them.
SecretString fields
rullst_orm::SecretString is an alternative for a field that should be
encrypted by its SQLx codec and redacted from Debug. Its serde form is never
plaintext: Serialize writes a RULLST:v2 envelope under the current key (and
fails without one), and Deserialize decrypts such an envelope with the
current key or keyring while still accepting an ordinary string as input.
Generated audit, event and search projections write "***" instead, and the
.remember(...) query cache stores the envelope. A #[derive(Serialize)] on
the model therefore produces ciphertext for this field; call
reveal_audited() where the plaintext is deliberately required.
3. Rotate a key without downtime
Set the new current key and keep old readable keys in a JSON keyring:
export RULLST_ENCRYPTION_KEY="base64:<new-32-byte-key>"
export RULLST_ENCRYPTION_KEY_ID="production-2027-01"
export RULLST_ENCRYPTION_KEYRING='{
"production-2026-01": "base64:<old-32-byte-key>"
}'
Reads select the key named by the envelope. A normal full save() rewrites all
annotated values with the current key. Keep every key needed by existing rows
until a separately monitored migration has rewritten and verified them; then
remove the retired key.
4. Understand query limits
AES-GCM encryption is randomized, so the same plaintext produces different
ciphertexts. Generated queries reject encrypted fields in WHERE, ORDER BY,
GROUP BY, and explicit SELECT clauses rather than silently returning wrong
results. pluck_string supports non-null encrypted strings; load the model for
nullable encrypted strings.
For lookup, add a separate application-designed blind-index column and assess its equality-leakage and key-rotation trade-offs. Rullst does not generate a blind index automatically. Raw SQL is an explicit escape hatch and does not automatically encrypt bindings or decrypt arbitrary projections.
5. Reduce secret lifetime in memory
VaultSecret<T> redacts Debug/Display and calls Zeroize on the wrapped
value when dropped:
#![allow(unused)]
fn main() {
use rullst_security::VaultSecret;
fn use_api_key() {
let Ok(value) = std::env::var("EXAMPLE_PROVIDER_KEY") else {
return;
};
let api_key = VaultSecret::new(value);
send_request(api_key.expose_secret());
}
fn send_request(_: &str) {}
}
Zeroization is defense in depth, not secure memory. It cannot erase prior copies or prevent a debugger, core dump, swap, allocator behavior, or another process-memory capture while the secret is live.
Operational checklist
- Back up the database and keyring together, with separate access controls.
- Test restore and rotation before retiring any key.
- Restrict environment and crash-dump access.
- Never log plaintext model fields or expose them through serialization by
accident. Generated
to_json()already replaces encrypted fields with"***"; add#[orm(hidden)]when a field must be omitted entirely. A#[derive(Serialize)]on the model is your own serializer and still emits the decrypted value. - Treat authentication failure as possible corruption, wrong context, wrong key, or tampering; do not replace it with an empty value.
Tutorial 16: LiveView Server-Driven UI (rullst::live) ⚡
Build a per-connection Rust component that receives JSON events and sends rendered HTML over WebSockets. The browser still needs the HTMX WebSocket extension (or a compatible client transport).
This tutorial documents the legacy per-connection API. For the v13 prerelease
candidate with explicit authorization, revision conflicts, bounded reconnection
and recovery after process restart, use Recoverable Live UI.
Its browser module and application trait are separate; make:live does not
automatically supply that contract.
🛠️ Step 1: Scaffold a LiveComponent
cargo rullst make:live CounterComponent
This creates src/live/counter_component.rs and declares it in
src/live/mod.rs. Declare mod live; in the crate root yourself; the command
does not edit src/main.rs or src/lib.rs.
💻 Step 2: Implement the Component Lifecycle
Edit the generated src/live/counter_component.rs, for example:
#![allow(unused)]
fn main() {
use rullst::async_trait;
use rullst::live::LiveComponent;
use serde_json::Value;
#[derive(Default)]
pub struct CounterComponent {
pub count: i32,
}
#[async_trait]
impl LiveComponent for CounterComponent {
async fn mount(&mut self) {
self.count = 10;
}
async fn handle_event(&mut self, payload: Value) {
if let Some(action) = payload.get("action").and_then(|v| v.as_str()) {
match action {
"increment" => self.count += 1,
"decrement" => self.count -= 1,
_ => {}
}
}
}
fn render(&self) -> String {
format!(
r#"<div id="counter-component" class="p-6 bg-slate-800 text-white rounded-xl">
<h2 class="text-xl font-bold">Counter: {}</h2>
<button ws-send name="action" value="increment" class="px-4 py-2 bg-emerald-600 rounded">+1</button>
</div>"#,
self.count
)
}
}
}
💻 Step 3: Mount Component in a Controller
This route fragment belongs in the same application module where
CounterComponent is in scope:
use rullst::html::Markup;
use rullst::live::Live;
use crate::live::counter_component::CounterComponent;
// `Live::mount` returns `Markup`: return it as the page, or interpolate it
// into an `html!` layout as `{component}`.
pub async fn page_handler() -> Markup {
Live::mount::<CounterComponent>("/ws/counter").await
}
Register the page and the matching WebSocket route, for example in the
routes![...] list of router():
use rullst::live::live_ws_handler;
let app = rullst::routes![
get("/counter" => page_handler),
get("/ws/counter" => live_ws_handler::<CounterComponent>),
];
The page must also load HTMX and its WebSocket extension (see the takeaways).
The command’s own hint uses /ws/counter_component; any path works as long as
Live::mount and the route name the same one.
💡 Key Takeaways
- Event payloads travel over WebSocket connections;
render()produces updated HTML fragments. - The current component state lives in one socket task. Authentication, authorization, reconnect/replay, backpressure and multi-process state remain application concerns.
live_ws_handleraccepts WebSocket frames and messages of at most 64 KiB; a larger message closes the session before it is parsed. Origin checks, connection caps and idle timeouts are still route-level policy.- Include and pin the HTMX WebSocket extension; Rullst does not inject that browser dependency automatically.
Tutorial 17: In-process realtime and presence
Rullst Core provides bounded in-process broadcast channels and presence state. These primitives do not create an HTTP WebSocket endpoint by themselves and do not synchronize independent server processes.
Subscribe before publishing
#![allow(unused)]
fn main() {
use std::sync::Arc;
use rullst::realtime::{BroadcastManager, RealtimeError, RealtimeMessage};
async fn local_exchange() -> Result<RealtimeMessage, RealtimeError> {
let manager = Arc::new(BroadcastManager::new());
let mut receiver = manager.get_or_create("room:general").subscribe();
manager.publish(
"room:general",
"message.created",
r#"{"sender":"alice","content":"hello"}"#,
)?;
receiver
.recv()
.await
.map_err(|error| RealtimeError::BroadcastError(error.to_string()))
}
}
Channel uses tokio::sync::broadcast: a slow receiver can lag, and publishing
without any receiver returns RealtimeError::BroadcastError.
The registry keeps a channel only while it has a subscriber or a caller holds
the Arc<Channel> from get_or_create. Publishing never creates a channel, and
idle channels are released by a publish that finds no receivers or by an
amortized sweep when new channels are created; a released name gets a new
channel on its next get_or_create. PresenceTracker counts connections:
call user_left once for every user_joined (for example when each socket
closes), so a user with two open tabs stays online until both close. It
removes a room when its last user leaves.
Bind channels and presence to an authenticated tenant
Construct TenantContext only from trusted authentication/membership state.
The wrappers validate logical names and ensure that identical room names use
different backend namespaces:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use rullst::realtime::{
BroadcastManager, PresenceTracker, TenantPresence, TenantRealtime,
};
use rullst::security::TenantContext;
fn publish_for_school() -> Result<(), Box<dyn std::error::Error>> {
let context = TenantContext::try_new("school-alpha")?;
let realtime = TenantRealtime::from_context(
Arc::new(BroadcastManager::new()),
&context,
);
let presence = TenantPresence::from_context(
Arc::new(PresenceTracker::new()),
&context,
);
let mut receiver = realtime.subscribe("course/42")?;
presence.user_joined("course/42", "learner-7")?;
realtime.publish("course/42", "lesson.completed", r#"{"lesson_id":9}"#)?;
assert_eq!(presence.count_online("course/42")?, 1);
assert!(receiver.try_recv().is_ok());
Ok(())
}
}
The tenant wrapper limits names and payloads (64 KiB), but the application still
owns authentication, room-level authorization, connection lifecycle and replay.
Use rullst::live::live_ws_handler for the separate per-connection LiveComponent
flow (Tutorial 16), or build an Axum
WebSocket handler around these primitives. A distributed
transport is still roadmap work.
Tutorial 18: Wasm Island foundation
Rullst can generate a dual-target Island function: native builds emit a host
element with serialized props, while wasm32-unknown-unknown builds export a
hydration function.
Generate the component
cargo rullst make:island InteractiveChart
The generated src/islands/interactive_chart.rs uses the supported macro:
#![allow(unused)]
fn main() {
use rullst::island;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::{closure::Closure, JsCast};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InteractiveChartProps {
pub initial_value: i32,
}
#[island]
pub fn interactive_chart(props: InteractiveChartProps) {
#[cfg(not(target_arch = "wasm32"))]
{
format!(
"<button type=\"button\" data-counter=\"{}\">Count: {}</button>",
props.initial_value, props.initial_value
)
}
#[cfg(target_arch = "wasm32")]
{
let mut value = props.initial_value;
element.set_text_content(Some(&format!("Count: {value}")));
let button = element.clone();
let closure = Closure::<dyn FnMut()>::new(move || {
value = value.saturating_add(1);
button.set_text_content(Some(&format!("Count: {value}")));
});
if element
.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())
.is_ok()
{
closure.forget();
}
String::new()
}
}
}
The element binding in the Wasm block is supplied by #[island]. The command
also declares the module in src/islands/mod.rs, adds pub mod islands; to
src/lib.rs (creating that file when it is absent), and adds any missing
serde, serde_json, wasm-bindgen and web-sys dependencies.
On the server the island function returns rullst::html::Markup: the
component’s HTML wrapped in its data-island element. Place it in a page by
interpolating the call inside html!, as {interactive_chart(props)}; it is
inserted as HTML, not escaped. The module belongs to the library target
(src/lib.rs), so the starters’ src/main.rs imports it through the package
name, with - written as _:
use <package_name>::islands::interactive_chart::{interactive_chart, InteractiveChartProps};.
Build and load the artifact
cargo rullst build:client
The command parses Cargo.toml, adds cdylib to the [lib] crate-type
array when needed (writing ["cdylib", "rlib"] when the key is absent),
installs/checks the Wasm target and wasm-bindgen-cli, builds the library,
locates the artifact using lib.name or package.name, writes bindings under
static/, and generates the static/rullst-islands.js hydration orchestrator.
Review these manifest, network, and toolchain side effects in CI and pin the
required tools for reproducible releases. Load the orchestrator from the page
with <script type="module" src="/static/rullst-islands.js"></script>, as the
command output shows.
This is a useful foundation, not a complete frontend framework: routing, application state, accessibility, CSP-compatible asset delivery, cache busting, error reporting and browser E2E remain application/release work.
Tutorial 19: SaaS billing with Rullst Capital 💳
This tutorial creates a checkout adapter boundary and explains how to feed the optional local Studio view without treating it as an accounting system.
1. Scaffold and configure the selected adapter
cargo rullst make:billing --model Workspace
The command detects a relational SQLx or Turso-primary project, adds the exact
orm and capital facade features once, generates a reversible matching
migration, registers the models/controller/page modules, and refuses to
overwrite an earlier billing scaffold. The generated runtime supports the
Stripe live integration or explicit offline fixtures; BILLING_PROVIDER
accepts only stripe. To bill through another gateway, implement the Capital
provider traits in the application as described in
Writing your own payment provider. v13
removed the other built-in adapters; see the
v13 migration guide.
The generated Stripe flow persists an opaque local owner, account/test-live scope, immutable customer and checkout intents, session IDs, and event receipts. Signed Checkout/subscription notifications reconcile current provider state. Database revision fencing prevents delayed reads from replacing newer state, and event completion shares the subscription transaction.
Configure BILLING_PROVIDER=stripe, BILLING_ACCOUNT_ID, BILLING_API_KEY,
BILLING_WEBHOOK_SECRET, an HTTPS BILLING_REDIRECT_URL, and the explicit
BILLING_ALLOWED_PLAN_IDS recurring-price allowlist in the process environment
or the project’s .env (in v13 the generated code reads the process environment
first, then .env, through rullst::config::project_setting). Follow the generated
BILLING.md for webhook event selection, pinned API version, CSP, recovery and
migration details. Keep credentials outside source control. Test and live keys
use separate persisted namespaces; mixed real/mock credentials fail closed.
Live keys also require
BILLING_LIVE_ACKNOWLEDGEMENT=I_UNDERSTAND_REAL_CHARGES; without it the
generated live path stays unavailable.
Existing controllers are application-owned: merge the generated modules and add the billing-state/event tables through a new migration. Updating a dependency does not rewrite existing controllers or safely backfill legacy customer ownership.
2. Create a checkout session
Mount the generated authenticated checkout POST behind session/tenant ownership and CSRF verification. The form supplies only an allowlisted plan. The generated handler persists the intent before dispatch and the session ID before its HTTP 303 redirect. Repeated requests retrieve and reuse an open session. Older unknown outcomes use read-only recovery rather than new keys after provider retention.
When calling the low-level adapter directly, persist and authorize the same customer/attempt boundary explicitly:
#![allow(unused)]
fn main() {
use rullst_capital::{CapitalError, StripeCheckoutRequest, StripeProvider};
async fn checkout_url(
stripe: &StripeProvider,
persisted_attempt: &StripeCheckoutRequest,
) -> Result<String, CapitalError> {
let session = stripe.create_subscription_checkout(persisted_attempt).await?;
// Persist session.id() and its digest before returning the URL to the browser.
Ok(session.url().to_owned())
}
}
Email is optional contact data, never the webhook owner key. The ID-bound portal uses the same persisted customer. The application supplies authentication and membership, configured products/prices, retention, entitlement/settlement policy, and actual provider-account sandbox acceptance. Navigation and session creation never grant paid access.
2.1 Handle provider failure without blindly repeating a charge
Built-in live adapters return a redacted CapitalError::Provider for outbound
request construction, transport, HTTP status, bounded-response, JSON, and
response-contract failures. Inspect its stable class for telemetry or a durable
job decision:
#![allow(unused)]
fn main() {
use rullst_capital::{CapitalError, ProviderFailureClass};
fn provider_disposition(error: &CapitalError) -> Option<ProviderFailureClass> {
match error {
CapitalError::Provider(failure) => Some(failure.class()),
_ => None,
}
}
}
Do not turn Transient or RateLimited into an unconditional loop. Persist the
original command and idempotency identity, confirm that the selected operation
actually forwards that identity, cap attempts with backoff/jitter, and
reconcile signed provider events. Checkout creation in the legacy unified trait
does not accept an application idempotency key, so reconcile before repeating
it. The shared client already applies finite timeouts, disables redirects and
ambient proxies, caps JSON to one MiB, and validates returned checkout URLs as
absolute credential-free HTTPS.
3. Make a bounded immediate charge when checkout is not the right flow
For a payment method already tokenized and authorized for off-session reuse at
Stripe, the model deriving Billable can perform one fully specified charge:
#![allow(unused)]
fn main() {
use rullst_capital::{Billable as _, CapitalError, StripeProvider};
async fn charge_saved_method(
account: &(impl rullst_capital::Billable + Sync),
stripe: &StripeProvider,
) -> Result<String, CapitalError> {
let receipt = account
.charge_with(
stripe,
2_500,
"BRL",
"cus_from_authoritative_state",
"pm_from_authoritative_state",
"order_2026_0001-attempt_1",
)
.await?;
Ok(receipt.charge_id().to_string())
}
}
This deliberately is not charge(amount): currency, provider customer,
tokenized payment method and retry identity cannot be inferred safely. The
Stripe adapter uses Payment Intents with immediate off-session confirmation and
the provider idempotency header. Only succeeded and processing are accepted;
an amount/currency mismatch or a flow requiring customer action fails closed.
Credentials beginning with mock_ return the same deterministic receipt with
the distinct non-success ChargeStatus::Mock for an exact retry. The mock is
not a mandate, durable idempotency store or live sandbox test. Other adapters
return UnsupportedOperation until reviewed individually.
4. Render and deliver the invoice only after final success
Enable capital-mail on the umbrella crate (or invoice-pdf on Capital plus
capital-invoice on Mail). Build the invoice from authoritative order state,
then bind it to the returned receipt:
#![allow(unused)]
fn main() {
use chrono::Utc;
use rullst::capital::{ChargeReceipt, Invoice, InvoiceItem};
use rullst::mail::PaidInvoiceDelivery;
async fn deliver_invoice(receipt: &ChargeReceipt) -> Result<(), Box<dyn std::error::Error>> {
let invoice = Invoice {
invoice_id: "INV-2026-0001".to_string(),
customer_email: "[email protected]".to_string(),
date: Utc::now(),
items: vec![InvoiceItem {
description: "Pro subscription".to_string(),
amount: 25.00,
}],
total: 25.00,
currency: "BRL".to_string(),
};
let paid = invoice.bind_succeeded_charge(receipt)?;
// Real providers require a sender on a domain verified for the account.
let delivery = PaidInvoiceDelivery::prepare(&paid)?.from("[email protected]")?;
// In production, atomically claim this stable key in a durable outbox.
let _delivery_key = delivery.delivery_key();
delivery.send().await?;
Ok(())
}
}
The binding rejects Processing, Mock, a mismatched recipient, amount or
currency. The default PDF is paginated, bounded to sixteen MiB and supports
WinAnsi text (including common Portuguese characters); pass a checked TTF/OTF
to Capital for other scripts. Mail applies its mandatory pre-flight before the
facade queues or sends the HTML message and attachment; from re-runs it for
the verified sender, which every real transport requires: none invents one.
This helper does not subscribe to webhooks by itself. Reconcile the provider
event, build the authoritative invoice and insert delivery_key under a unique
database constraint in the same application workflow. Mail delivery remains
at least once: a crash and retry can still require provider/application
deduplication.
5. Report metered usage without confusing provider identities
Use the provider-specific static trait for new code. Stripe Meter Events need a customer ID and configured event name, not a subscription-item ID:
#![allow(unused)]
fn main() {
use rullst_capital::{
CapitalError, MeteredBillingProvider as _, StripeMeterEvent, StripeProvider,
};
async fn report_ai_exercises(stripe: &StripeProvider) -> Result<(), CapitalError> {
let event = StripeMeterEvent::new(
"cus_from_authoritative_state",
"ai_exercises",
3,
"usage:school-7:attempt-99",
)?;
let receipt = stripe.report_metered_usage(&event).await?;
assert_eq!(receipt.quantity(), 3);
Ok(())
}
}
Stripe receives the identifier but enforces it only within a rolling window, so
keep a durable application record of submitted events. A custom adapter whose
provider receives no event key reports
UsageDeduplication::ApplicationOutboxRequired; durable application
deduplication is then mandatory. The adapter caps and binds responses, while
provider sandbox/live acceptance, retry, invoice reconciliation and entitlement
updates remain release and application work. Empty or mock_* API keys produce a deterministic
UsageStatus::Mock, never billable evidence.
6. Verify webhooks before business processing
Mount rullst_capital::verify_webhook on the exact provider callback route as
shown in the Capital crate guide. Apply any CSRF
exemption only to that exact signed route. Never update access or subscription
state from an unverified request.
The default replay store protects one process. For multiple processes,
rullst-capital/webhook-sql provides a bounded SQL ledger for SQLite,
PostgreSQL, MySQL, and MariaDB. Its middleware form claims the signed payload
before dispatch and therefore does not guarantee exactly-once processing after
a crash. When a subscription mutation must be atomic, use the provider’s
verified stable event ID with
SqlWebhookReplayStore::check_and_record_event_key_with_transaction in the
same database transaction as that mutation; do not also pre-claim that event
through SQL middleware. See the payment guide
for setup and operational boundaries.
7. Use a bounded subscription handle and grace period
#![allow(unused)]
fn main() {
use rullst_capital::{Billable as _, CapitalError, GracePeriod, StripeProvider};
async fn pause_with_local_policy(
workspace: &impl rullst_capital::Billable,
provider: &StripeProvider,
) -> Result<(), CapitalError> {
let grace = GracePeriod::new(1_900_000_000, 1_900_604_800)?;
let handle = workspace
.subscription_with(provider)?
.with_grace_period(grace);
handle.pause().await
}
}
The grace value does not schedule the pause or grant access by itself. Persist it with authoritative subscription state, evaluate it against a trusted clock inside the entitlement check, and confirm the selected adapter’s live pause or cancel semantics.
The same statically dispatched handle validates coupon IDs and gives the historical relative-trial API its intended meaning:
#![allow(unused)]
fn main() {
use rullst_capital::{Billable as _, CapitalError, StripeProvider};
async fn grant_a_retention_offer(
workspace: &impl rullst_capital::Billable,
stripe: &StripeProvider,
command_created_at: i64,
) -> Result<(), CapitalError> {
let subscription = workspace.subscription_with(stripe)?;
subscription.apply_coupon("RETENTION_25").await?;
// Fifteen whole days. Persist command_created_at and reuse it on retries.
subscription
.extend_trial_days_at(15, command_created_at)
.await
}
}
extend_trial(15) uses the current UTC time for interactive convenience.
Workers should persist their command time and use extend_trial_days_at so a
retry sends the identical expiration; set_trial_end is the explicit absolute
timestamp operation. Stripe has the reviewed live coupon path and reviewed
trial-update protocol fixtures; adapters without those contracts fail
explicitly. Authorize the subscription owner before
building the handle, serialize conflicting updates, and reconcile the signed
provider webhook because trial changes can affect billing anchors and charges.
8. Enforce one shared workspace quota before creation
Enable quota-sql directly, or capital-quota-sql on the umbrella crate. The
authenticated middleware must first establish the active TenantContext; do
not build a billing subject from an arbitrary header or request field.
Billable::quota_request reads the limit from the subscription owner’s
tier_limit implementation. A limit of Some(0) (a plan that grants none of
the feature) returns QuotaError::LimitExceeded, the same denial as a used-up
limit. Give every attempted creation a stable event key,
normally the ID of the application command/request rather than a random value
generated on every retry.
#![allow(unused)]
fn main() {
use rullst::{
capital::{Billable as _, BillingSubject, QuotaError, SqlQuotaStore},
security::TenantContext,
};
async fn create_project(
workspace: &impl rullst::capital::Billable,
tenant: &TenantContext,
quotas: &SqlQuotaStore,
project_id: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
let subject = BillingSubject::from_tenant(tenant)?;
let request = workspace.quota_request(
subject,
"projects",
format!("create-project:{project_id}"),
1,
)?;
let mut transaction = quotas.pool().begin().await?;
let grant = match quotas
.reserve_with_transaction(&mut transaction, &request)
.await
{
Ok(grant) => grant,
Err(QuotaError::LimitExceeded { .. }) => {
transaction.rollback().await?;
return Ok(false);
}
Err(error) => {
transaction.rollback().await?;
return Err(error.into());
}
};
if grant.is_replay() {
transaction.rollback().await?;
return Ok(true);
}
let inserted = rullst::orm::sqlx::query(
"INSERT INTO projects (id, workspace_id) VALUES (?, ?)",
)
.bind(project_id)
.bind(tenant.tenant_id.as_str())
.execute(&mut *transaction)
.await;
if let Err(error) = inserted {
transaction.rollback().await?;
return Err(error.into());
}
transaction.commit().await?;
Ok(true)
}
}
The placeholder above is SQLite/MySQL syntax; use $1, $2 for a raw
PostgreSQL insert, or use the ORM operation that participates in the same
transaction. SqlQuotaStore uses a unique event claim plus a conditional
counter update, so concurrent members cannot both pass the last available
unit. An exact retry returns is_replay() without consuming again; reusing the
same key with different units or a different limit fails closed.
For work that cannot share the SQL transaction, QuotaGate::execute still
blocks the callback before an over-limit/replayed operation and releases the
reservation after an ordinary callback error. A process crash between a
standalone reservation and the external side effect is intentionally
conservative and needs application reconciliation; the framework never risks
exceeding the quota to guess whether that external effect happened.
9. Supply an optional local revenue snapshot
RevenueDashboardManager does not derive money or subscribers from event names.
After durable reconciliation, the application may call update_metrics with its
authoritative snapshot and record_event with a bounded inspection record. The
standalone Studio can display that process-local source at /studio/capital
when it is explicitly connected. It neither auto-discovers webhook routes nor
auto-syncs the application database.
Provider-hosted checkout normally keeps card collection away from the application, but the final data boundary depends on the selected provider flow, application logs, analytics, and deployment.
Tutorial 20: Background Jobs & Task Queues ⚙️
Rullst Core provides named JSON jobs, SQLite and Redis storage drivers, and a
bounded-concurrency worker. Job handlers are closures registered against a
stable name; there is no Job trait or process-global Queue::dispatch API.
Step 1: Scaffold a handler module
cargo rullst make:worker EmailWorker
This creates src/workers/email_worker.rs, registers it in
src/workers/mod.rs (declared in src/main.rs), and provides
start_workers(worker), which registers every generated handler and starts the
worker. Replace the generated log-only body with the application operation.
Avoid logging the complete job payload, because it can contain personal or
secret data.
Step 2: Create a queue and keep its worker handle alive
Queue::sqlite needs the queue-sqlite feature of rullst, which the starters
do not enable: add "queue-sqlite" to the features list of the rullst
dependency in Cargo.toml. The example below registers its handler by hand;
with the generated module, pass the Worker to workers::start_workers
instead.
#![allow(unused)]
fn main() {
use rullst::queue::{Queue, QueueError, Worker, WorkerHandle};
use serde_json::json;
fn start_email_worker(queue: &Queue) -> Result<WorkerHandle, QueueError> {
let mut worker = Worker::new(queue)
.max_concurrency(8)
.poll_interval(250);
worker.register("email", |payload| async move {
let recipient = payload
.get("recipient")
.and_then(|value| value.as_str())
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "missing recipient")
})?;
// Call an idempotent mail service with a stable delivery key here.
let _ = recipient;
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
});
worker.run()
}
async fn configure() -> Result<(Queue, WorkerHandle), QueueError> {
let queue = Queue::sqlite("sqlite://queue.db?mode=rwc").await?;
let handle = start_email_worker(&queue)?;
queue
.dispatch(
"email",
json!({"recipient": "[email protected]", "delivery_key": "welcome:42"}),
)
.await?;
Ok((queue, handle))
}
}
Dropping WorkerHandle stops processing. On graceful shutdown, call
handle.shutdown().await and inspect its typed error. It stops polling and
cancels running handlers; the SQLite and Redis drivers requeue each job whose
handler was actually cancelled, while a handler that finished first keeps its
own result. The worker never cancels a claim in flight: graceful shutdown waits
for the current pop, and a job claimed after shutdown was requested is
requeued instead of dispatched.
Dropping the handle aborts immediately, so such a claim returns only through
stalled-lease recovery.
Use Queue::redis(redis_url) with the queue-redis feature when independent
processes must share work. SQLite is durable local state and supports atomic
claims, but it is not a distributed queue.
Failure, retry, and delivery semantics
- A handler error, panic, or timeout is recorded as failed. Core does not automatically retry it with exponential backoff.
retry_failed_job(job_id)explicitly returns a failed job to pending state.- SQLite and Redis recover stale processing leases and support scheduled jobs, but execution time is the first worker poll after the due timestamp.
- Queue processing is at least once around crashes and external side effects. Give provider operations stable idempotency keys and reconcile ambiguous outcomes.
- Successful payloads are deleted by default. SQLite completed history is an explicit bounded opt-in with its own privacy/access-control obligations.
See the Redis Architecture Guide for deployment boundaries.
Tutorial 21: Explicit Local AI with Ollama 🤖
Use an explicit OllamaProvider when a workload must not fall through to a
configured cloud provider. AiClient::auto() is a convenience fallback chain;
it is not an isolation policy when cloud API keys are also present.
You need the ai feature of rullst (cargo rullst new --ai, or
cargo rullst add ai in an existing project) and an Ollama server you run and
provision yourself.
Step 1: Configure the local endpoint
Add both settings to the process environment or the project’s .env:
OLLAMA_HOST=http://127.0.0.1:11434
OLLAMA_MODEL=llama3:8b
Rullst never loads .env into the process environment, so the code below reads
them with rullst::config::project_setting (the process environment first, then
.env), the same way the generated starters read their own settings.
Bind Ollama to loopback or a controlled private interface. Pull and license the
chosen model (ollama pull llama3:8b) through a reviewed provisioning step
rather than silently during a request.
Step 2: Construct the provider explicitly
#![allow(unused)]
fn main() {
use rullst::ai::{AiClient, AiError, providers::ollama::OllamaProvider};
/// Reads a setting from the process environment, then `.env`.
fn required_setting(name: &str) -> Result<String, AiError> {
rullst::config::project_setting(name)
.map_err(|error| AiError::ConfigError(error.to_string()))?
.ok_or_else(|| AiError::ConfigError(format!("{name} is required")))
}
pub async fn summarize_lesson(text: &str) -> Result<String, AiError> {
let host = required_setting("OLLAMA_HOST")?;
let model = required_setting("OLLAMA_MODEL")?;
let client = AiClient::new(OllamaProvider::new(host, model));
client
.prompt(&format!(
"Summarize this lesson in three factual bullet points:\n{text}"
))
.await
}
}
A missing setting returns AiError::ConfigError, and a server that cannot be
reached returns AiError::RequestError; neither falls back to another provider.
The high-level client applies Rullst’s mandatory prompt/PII guardrails before dispatch. Model output remains untrusted: escape it for HTML, validate structured data, and never use an LLM verdict as the only authentication, authorization, malware, or abuse-control decision.
Isolation checklist
- Confirm the resolved endpoint, host firewall, container network, DNS, proxy, and telemetry configuration.
- Remove cloud-provider credentials from the process when policy requires local only; an explicit provider prevents fallback but least privilege still helps.
- Review application/Ollama logs, model caches, backups, crash dumps, and swap for sensitive data.
- Empty or
mock_*Ollama hosts select deterministic offline mock behavior; a mock response is test evidence, not evidence that a live model ran.
“Local” does not prove an air gap. Verify the complete deployed data flow.
22. RAG Systems & Vector Search
Important
Dependency examples pin the v13 prerelease
=13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and the v12 line reached end of life on 16 October 2026 (see the release record). Commit Cargo.lock for reproducible builds. Use path dependencies only when intentionally testing checkout-local changes.
Rullst provides three deliberately separate vector paths: a deterministic
in-memory index in rullst-ai, parameterized PostgreSQL pgvector queries,
and a bounded Qdrant HTTP adapter in rullst-orm. None silently invent tenant authorization, context
budgets, an embedding model, or a production RAG policy.
In-memory retrieval
VectorIndex is useful for bounded local datasets and tests:
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["ai"] }
serde_json = "1.0"
#![allow(unused)]
fn main() {
use rullst::ai::VectorIndex;
let mut index = VectorIndex::new();
index.add(
"rullst",
vec![1.0, 0.0, 0.0],
serde_json::json!({"text": "Rullst is a Rust framework suite."}),
);
index.add(
"other",
vec![0.0, 1.0, 0.0],
serde_json::json!({"text": "An unrelated document."}),
);
let matches = index.search(&[0.9, 0.1, 0.0], 3);
assert_eq!(matches[0].1.id, "rullst");
}
The caller supplies the embedding and the limit. This process-local index is not durable, distributed, tenant-aware, or an approximate-nearest-neighbor service.
PostgreSQL + pgvector
Enable the typed vector and concrete PostgreSQL paths:
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = [
"orm-pgvector",
"strict-postgres",
"ai",
] }
# `#[derive(Orm)]`, the model's `FromRow` included, expands to `rullst_orm::…`
# paths, so a crate that derives models depends on it directly.
rullst-orm = { version = "=13.0.0-alpha.4", default-features = false, features = ["strict-postgres"] }
Projects generated with cargo rullst new --database postgres already list
the rullst-orm line; add orm-pgvector and ai to their rullst
features.
Install the extension through a reviewed PostgreSQL migration and choose the dimension used by your embedding provider:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE knowledge_chunks (
id SERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536) NOT NULL
);
The model can use the re-exported typed Vector:
#![allow(unused)]
fn main() {
use rullst::orm::{Orm, Vector};
#[derive(Clone, Debug, Orm)]
#[orm(table = "knowledge_chunks", tenant_column = "tenant_id")]
struct KnowledgeChunk {
id: i32,
tenant_id: String,
content: String,
embedding: Vector,
}
}
Generate the query embedding through a configured guarded AI client, establish the authenticated tenant scope, and then use the native operators:
#![allow(unused)]
fn main() {
use rullst::ai::AiClient;
use rullst::orm::{Orm, Vector, with_tenant};
#[derive(Clone, Debug, Orm)]
#[orm(table = "knowledge_chunks", tenant_column = "tenant_id")]
struct KnowledgeChunk {
id: i32,
tenant_id: String,
content: String,
embedding: Vector,
}
async fn retrieve(
client: &AiClient,
question: &str,
) -> Result<Vec<KnowledgeChunk>, Box<dyn std::error::Error>> {
let embedding = client.embed(question).await?;
let query: Vec<f64> = embedding.iter().map(|value| f64::from(*value)).collect();
let chunks = with_tenant("tenant-42", async move {
KnowledgeChunk::query()
.where_similar("embedding", query.clone(), 0.8)
.order_by_cosine_distance("embedding", query)
.limit(5)
.get()
.await
})
.await?;
Ok(chunks)
}
}
The two clauses use different metrics: where_similar keeps rows whose L2
distance (<->) is below the bound, while order_by_cosine_distance sorts by
cosine distance (<=>). order_by_l2_distance and order_by_inner_product
(<#>) are also available; choose the bound for the L2 metric.
Vector and distance values are SQL bindings, not interpolated literals. Column
names use the normal identifier validation, vectors must be finite/non-empty,
and the distance must be finite and non-negative. The pgvector feature also
supplies SQLx encode/decode for the typed field. Rullst’s live contract creates
the extension, inserts typed vectors, and executes L2 and cosine queries against
a digest-pinned pgvector/pgvector container.
Qdrant dense-vector store
Use Qdrant when the application deliberately chooses a specialized external vector service rather than keeping vectors in PostgreSQL:
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = [
"orm-qdrant",
"ai",
] }
serde_json = "1.0"
#![allow(unused)]
fn main() {
use rullst::orm::{
QdrantConfig, QdrantStore, VectorCollectionName, VectorDimensions,
VectorPoint, VectorQueryLimit, VectorRepository,
};
use serde_json::{Map, Value};
async fn qdrant_example() -> Result<(), Box<dyn std::error::Error>> {
let config = QdrantConfig::new(
std::env::var("QDRANT_URL").unwrap_or_default(),
std::env::var("QDRANT_API_KEY").unwrap_or_default(),
);
let vectors = QdrantStore::connect_or_mock(config)?;
let collection = VectorCollectionName::new("knowledge-v1")?;
vectors
.create_collection(&collection, VectorDimensions::new(3)?)
.await?;
let mut payload = Map::new();
payload.insert("chunk_id".into(), Value::String("chunk-42".into()));
vectors
.upsert(
&collection,
VectorPoint::new(42, vec![1.0, 0.0, 0.0], payload)?,
)
.await?;
let matches = vectors
.search(
&collection,
&[0.9, 0.1, 0.0],
VectorQueryLimit::new(5)?,
)
.await?;
let _ = matches;
Ok(())
}
}
Empty or mock_* endpoint/API-key values select the deterministic in-process
backend. An explicit QdrantConfig::unauthenticated_local is available only
for loopback self-hosting. The live contract is deliberately limited to one
unnamed dense cosine vector per numeric point. It bounds identifiers,
dimensions, finite/non-zero-norm vectors, 1 MiB object payloads, top-k, request
and response memory, deadlines and redirects. It does not claim named, sparse
or multivectors, arbitrary filters, hosted availability, ANN index tuning or
tenant authorization. A digest-pinned Qdrant lifecycle proves the supported
operations.
Orchestrate one bounded RAG operation
The compatibility build_rag_prompt helper only formats already-authorized
text. Prefer RagPipeline when the application needs one typed operation for
embedding, retrieval, context budgets, guarded generation, source metadata, and
mandatory auditing:
#![allow(unused)]
fn main() {
use rullst::ai::rag::{RagPipeline, RagRetriever};
fn compose<R, A>(client: rullst::ai::AiClient, retriever: R, audit: A)
where
R: RagRetriever,
A: rullst::ai::rag::RagAuditSink,
{
let pipeline = RagPipeline::new(client, retriever, audit);
let _ = pipeline;
}
}
The retriever receives a trusted tenant context and must enforce authoritative tenant and ownership predicates in its datastore query. The pipeline also rejects differently tagged documents and refuses ungrounded generation when no safe context remains. Follow the complete Tenant-Bound RAG tutorial for the offline index, production adapter boundary, and secret-minimized audit contract.
The application still owns embedding dimension/model compatibility, durable ingestion and deletion, citation evaluation, index tuning, authorization, output policy, observability, and recovery.
Tutorial 23: Process Telemetry & Prometheus Exporter 📡
Monitor process uptime, RSS memory, CPU, Tokio scheduler-yield latency, and active tasks using Rullst Radar (rullst::radar) and Prometheus (GET /metrics).
🛠️ Step 1: Mount Prometheus Exporter
In src/main.rs:
use rullst::radar::radar_metrics_router;
use rullst::{Router, Server, ServerError};
#[rullst::runtime::main]
async fn main() -> Result<(), ServerError> {
let app = Router::new().merge_axum(radar_metrics_router()); // Exposes GET /metrics
Server::new(app).run(3000).await
}
In a generated project, add .merge_axum(rullst::radar::radar_metrics_router())
to the router that main passes to Server::new, then run cargo rullst dev
and open http://localhost:3000/metrics.
📊 Step 2: Prometheus Metrics Scrape Output
Query GET /metrics:
# HELP rullst_uptime_seconds Process uptime in seconds.
# TYPE rullst_uptime_seconds counter
rullst_uptime_seconds 120
# HELP rullst_memory_rss_bytes Process RSS memory consumption in bytes.
# TYPE rullst_memory_rss_bytes gauge
rullst_memory_rss_bytes 24536678
# HELP rullst_cpu_usage_percent Process CPU utilization percentage.
# TYPE rullst_cpu_usage_percent gauge
rullst_cpu_usage_percent 1.25
# HELP rullst_tokio_active_tasks Total active Tokio tasks count.
# TYPE rullst_tokio_active_tasks gauge
rullst_tokio_active_tasks 4
# HELP rullst_tokio_latency_microseconds Observed Tokio scheduler yield latency in microseconds.
# TYPE rullst_tokio_latency_microseconds gauge
rullst_tokio_latency_microseconds 42
The CPU gauge needs an earlier sample in the same process, so the first collection omits it.
In a debug build the generated starters also start Studio, whose Radar page
shows the same process measurements: http://127.0.0.1:5555/studio/radar.
/metrics is not authenticated by radar_metrics_router(). Restrict it with a
private network, service-mesh policy, or reviewed authentication middleware;
process and runtime measurements can disclose operational information.
💡 Key Takeaways
- The response is a point-in-time local snapshot and allocates its text body.
- Linux and Windows expose supported process RSS/CPU probes. Tokio task data is available only inside a Tokio runtime; unsupported probes are omitted.
- The scheduler-yield observation is not a universal request-latency target.
Tutorial 24: Interactive Scalar API Documentation 📖
Scaffold a Scalar OpenAPI documentation router served at /docs.
🛠️ Step 1: Scaffold Scalar Docs
cargo rullst make:scalar
Run it in the project root. It generates src/controllers/docs_controller.rs
and declares it in src/controllers/mod.rs (creating both when they are
missing) and refuses to overwrite an existing controller. The command does not
edit route registration: the application must merge the router returned by
docs_controller::router().
💻 Step 2: Mount in Application
docs_controller::router() returns scalar_docs_router("/openapi.json"). This
self-contained example mounts the same router:
use rullst::{Router, Server};
use rullst::scalar::scalar_docs_router;
#[rullst::runtime::main]
async fn main() -> Result<(), rullst::ServerError> {
let app = Router::new().merge_axum(scalar_docs_router("/openapi.json"));
Server::new(app).run(3000).await
}
In a generated project, declare mod controllers; in src/main.rs if it is not
there yet (the Blank and API starters have no controllers module), and merge
the router where main builds the server; in the Blank and API starters that
is Server::new(router().merge_axum(controllers::docs_controller::router())).
Merge it there rather than inside router(): each starter’s router() adds
headers_middleware, whose default Content Security Policy blocks the page’s
remote and inline scripts, which leaves /docs blank.
📄 Step 3: Generate the specification and open the page
cargo rullst generate:openapi
cargo rullst dev
generate:openapi scans the project’s routes and writes openapi.json in the
project root. Open http://localhost:3000/docs in your browser to try the
endpoints interactively.
💡 Key Takeaways
- The current page loads a version-pinned Scalar asset from jsDelivr. A failed CDN load shows only a status page naming the OpenAPI document; it is not an offline interactive UI.
- The status-only fallback prints the configured OpenAPI location as text; it does not create an executable link from the configured value.
- In staging and production
rullst::Serverapplies the same security headers to every route, so the default CSP blocks the remote and inline assets there. Vendor the asset and integrate the page with the application’s nonce/hash policy before using it outside local development. - The router serves
/openapi.jsonfrom theopenapi.jsonfile in the process working directory (cargo rullst generate:openapiwrites it there); if it is missing or malformed, the endpoint fails with503 Service Unavailableinstead of fabricating an empty specification. Validate the release artifact in CI.
Tutorial 25: Kubernetes Manifests & Health Probes ☸️
cargo-rullst can generate a Kubernetes starter set. The files contain project
name/port defaults and must be reviewed for the target cluster, registry,
secrets, storage, network policy, ingress, workload identity, and security
policy before deployment.
Step 1: Generate the starter manifests
cargo rullst make:k8s
The command writes deployment.yaml, service.yaml, configmap.yaml,
hpa.yaml, ingress.yaml, and all-in-one.yaml under k8s/. It refuses to
run when any of those files already exists, so move customized manifests aside
to regenerate them, and review the diff.
Resource names, the image and the ingress host use the [package] name as a
lowercase RFC 1123 label, so a package named my_app becomes my-app; tag
your image with the same name. Replace the placeholder
image: <name>:latest with an immutable registry reference (preferably a
digest). The generated ConfigMap contains non-secret
settings only; use a Kubernetes Secret/external secret manager for credentials.
The Ingress selects ingressClassName: nginx and has a tls section, so
cert-manager’s ingress-shim asks the letsencrypt-prod ClusterIssuer for a
certificate and stores it in the <project>-tls Secret. Replace the placeholder
<project>.local host in both tls.hosts and rules with a public DNS name you
control (an ACME issuer cannot certify .local), and create the issuer or
change the annotation. Without TLS, the production configuration’s Secure
session cookies, HSTS and Nexus Basic Auth over verified TLS do not work.
Step 2: Mount lifecycle-aware health routes
Projects created by cargo rullst new already merge
rullst::health::health_router() into their router, so the generated probes
answer from the first deployment: /health and /ready both return 200 while
the process serves requests. To report startup, dependency and drain state on
/ready instead, replace that merge with the lifecycle-aware router:
use rullst::{ApplicationLifecycle, Router, Server};
use rullst::health::{health_router_with_lifecycle, init_health_boot_time};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
init_health_boot_time();
let lifecycle =
ApplicationLifecycle::with_required_components(["database", "queue"])?;
// Set these only after the application's own bounded checks succeed.
lifecycle.set_component_ready("database", true)?;
lifecycle.set_component_ready("queue", true)?;
let app = Router::new()
.merge_axum(health_router_with_lifecycle(lifecycle.clone()));
Server::new(app)
.with_lifecycle(lifecycle)
.run(3000)
.await?;
Ok(())
}
/health remains process-only so an external database outage does not create a
restart loop. /ready returns 503 during startup, while any registered
component is unavailable, during drain, and after stop. The JSON carries only
aggregate counts; it never emits the component labels or their error messages.
The version field of both probes is the Rullst framework version. To see
which build of your application a Pod runs, use
health_router_with_lifecycle_and_version(lifecycle.clone(), env!("CARGO_PKG_VERSION"))
(or health_router_with_version instead of health_router), which adds that
string as app_version.
The component registry is immutable, accepts at most 32 validated labels, and
does not execute probes by itself. The application must perform bounded,
timeout-protected checks and update each bit. Server marks startup complete
after binding, closes new application admission before graceful shutdown, and
waits through Axum for accepted requests. run_with_shutdown accepts a
supervisor future when OS signals are not the desired trigger.
This is one process contract. Kubernetes removes an unready Pod from service
according to its own timing; Rullst does not coordinate replica consensus,
load-balancer propagation, dependency failover, preStop, or the Pod’s
terminationGracePeriodSeconds. Measure those together in staging. The legacy
health_router() remains the simpler process-only pair when dependency-aware
admission is not requested.
Step 3: validate before applying
kubectl apply --dry-run=client -f k8s/all-in-one.yaml
kubectl diff -f k8s/all-in-one.yaml
kubectl apply -f k8s/all-in-one.yaml
The generated HPA uses autoscaling/v2 and requires a working resource metrics
pipeline. Validate against the actual cluster version and admission policies.
Release checklist
- add pod/container security contexts compatible with the reviewed image user;
- define CPU/memory requests and limits from measurements;
- add PodDisruptionBudget, topology spread/anti-affinity, NetworkPolicy, and service account/workload identity as required;
- terminate TLS with a configured issuer and real hostname;
- provision durable storage only where application state truly needs it; and
- verify probes, graceful termination, migrations, rollback, and autoscaling in a staging cluster.
Tutorial 26: Guided Container Deployment 🚀
cargo rullst deploy prepares the files you need to run your application on any
Linux server (a virtual machine from any provider, or your own machine) with
Docker or Podman. It writes them into your project and does not contact any
provider; you review them and run them yourself.
| File | Purpose |
|---|---|
Dockerfile | Multi-stage build with a dependency-cache layer and a small non-root runtime image. |
compose.yaml | Your application and Caddy (automatic HTTPS), a private network and volumes. Works with docker compose and podman compose. |
Caddyfile | The domain Caddy serves and the proxy to the application. |
Step-by-step guides that use these files:
Deploy to a Linux VM, with provider pages for
Hetzner Cloud, DigitalOcean,
Oracle Cloud and Google Cloud.
For Nix, Buildah, Podman Quadlet or Kubernetes see
Advanced deployment. The CLI no longer has
provider-specific targets (Fly.io, Railway, Render) or the Foundry SSH
pipeline; container platforms can still build the generated Dockerfile, but
their configuration is yours to write and review.
Before the first deploy
These staging and production defaults apply to every way of deploying:
- Migrations are a separate step by default.
Server::runonly opens the database pool, andcargo rullst devapplies pending migrations only in development. Run the application binary’sdb:migratecommand once per release, before the new version serves traffic:/app/<name> db:migratein the generated image (it has no shell), orcargo run --release -- db:migratefrom a checkout. Since 13.0.0-alpha.2 the server can instead apply them at startup: see startup migrations and the container lifecycle boundary. - Bind address. With
RULLST_ENV=stagingorproduction, the server listens on0.0.0.0unlessHOST(orRULLST_HOST) is set in the process environment or.env. Behind a reverse proxy on the same host, setHOST=127.0.0.1so the plain-HTTP port is reachable only through the proxy. Inside a container keep0.0.0.0and publish the port only to the proxy’s network. The server prints the address it listens on at startup (Rullst framework serving on http://0.0.0.0:3000 (every network interface)); since 13.0.0-alpha.2, when that is every interface and a trusted reverse proxy is configured ([security] trusted_proxiesorServer::trusted_proxies), the same line suggestsHOST=127.0.0.1for a proxy on the same host outside a container network and, since 13.0.0-alpha.4, keepingHOST=0.0.0.0when the proxy reaches the application over a container network (as in the generatedcompose.yaml). - Caching. The security baseline adds
Cache-Control: no-storeto every response without aCache-Controlof its own; the standard/staticmount sets its own. Downloads, feeds and other responses that browsers or a CDN may cache choose a policy withrullst::cache_control::public(max_age)orprivate(max_age)(13.0.0-alpha.2) on their route, or set the header themselves; see downloads and feeds.
Running the generated files
cargo rullst deploy
Then follow Deploy to a Linux VM.
Client addresses behind the proxy
Every deployment puts a reverse proxy in front of the application, so the
socket peer of each request is the proxy. Rate limits, such as the SaaS
starter’s ten login/registration submissions per client per minute, would then
treat every visitor as one client. The generated Caddy container has a fixed
address on the private network in compose.yaml, and cargo rullst deploy
lists that address in Rullst.toml (an existing trusted_proxies list is
left unchanged, with a warning):
[security]
trusted_proxies = ["172.31.250.10"]
Keep compose.yaml and Rullst.toml in step, and change the subnet if it
overlaps a network on the host. For any other proxy or platform, list the
networks it connects from, as documented by that platform. List only proxy
networks: any host inside them can choose the client address.
Container lifecycle boundary
The Dockerfile written by cargo rullst deploy, cargo rullst dockerize or
cargo rullst new --docker builds a non-root runtime image with production
host/port defaults and local assets, but never an application secret. For a
SQLite project (the starters’ default) the image sets
DATABASE_URL=sqlite:///app/data/rullst.db?mode=rwc, so the database lives in
/app/data; configure the target platform so that directory remains owned by
UID/GID 10001 when attaching persistent storage (the generated compose.yaml
mounts a volume there).
Apply migrations in one bounded pre-deployment job and start application
replicas only after it succeeds. Running migrations inside every replica is
opt-in (startup migrations), not the default. Starters
created by cargo rullst new mount rullst::health::health_router(), which
answers /health and /ready. An application created otherwise must mount
those routes before configuring probes or monitoring; a redirect to login is
not a health signal.
Startup migrations
Since 13.0.0-alpha.2 the server can apply pending migrations itself, after it opens the database pool and before it binds its port. It is off by default. Register the migrations and turn it on in code, per deployment, or both:
Server::new(router())
.with_migrations(crate::migrations::get_migrations())
.auto_migrate(true) // or leave it out and set RULLST_AUTO_MIGRATE=true
.run(3000)
.await?;
RULLST_AUTO_MIGRATE (true or false; 1/0, yes/no and on/off
also work), from the process environment and then .env, overrides
auto_migrate, so a deployment that runs db:migrate as a separate job can
set RULLST_AUTO_MIGRATE=false. Projects that cargo rullst new generates
with an SQLx database (Blank, API and SaaS)
already call with_migrations but not auto_migrate, so setting
RULLST_AUTO_MIGRATE=true is enough; a project generated before
13.0.0-alpha.2 needs the line above in src/main.rs. A Turso starter applies
its migrations with its own db:migrate command and registers none with the
server, so RULLST_AUTO_MIGRATE=true there stops startup with
startup migrations failed: no migrations are registered; call Server::with_migrations: leave it unset and run db:migrate.
Replicas that start together apply each migration once: the runner holds a
PostgreSQL advisory lock or a MySQL/MariaDB GET_LOCK lock, and for an SQLite
file an exclusive lock on <database>.rullst-migrate.lock (left on disk), while
it reads and applies the pending migrations; the others wait and then find
nothing to do. If a migration fails, or migrations are enabled without a
registry or a database, the server does not start: run returns
ServerError::Migration and the process exits with that error. A migration
must still be safe for the previous release, which keeps serving until the new
replicas are ready.
Tutorial 27: Typed Dependency Injection (rullst::di) 💉
Rullst DI uses Rust types as keys and avoids runtime reflection metadata. It is
not literally zero-cost: registration uses a map, resolution performs a
type-indexed lookup/downcast, and each injection clones an Arc.
Step 1: Register services without embedding secrets in source
#![allow(unused)]
fn main() {
use std::sync::Arc;
use rullst::di::Container;
use rullst::security_runtime::VaultSecret;
pub struct PaymentGateway {
api_key: VaultSecret<String>,
}
impl PaymentGateway {
pub fn is_configured(&self) -> bool {
!self.api_key.expose_secret().is_empty()
}
}
pub struct UserService {
pub gateway: Arc<PaymentGateway>,
}
pub fn configure_di() -> Result<Arc<Container>, std::env::VarError> {
let key = std::env::var("PAYMENT_API_KEY")?;
let mut container = Container::new();
let gateway = Arc::new(PaymentGateway {
api_key: VaultSecret::new(key),
});
container.register_arc(Arc::clone(&gateway));
container.register(UserService { gateway });
Ok(Arc::new(container))
}
}
security_runtime requires the umbrella security feature. The generated
starters depend on rullst-security directly, so they can use
rullst_security::VaultSecret instead. std::env::var reads only the process
environment (Rullst never loads .env into it); use
rullst::config::project_setting to fall back to .env in development. A
secret manager should inject the real value in production; VaultSecret
reduces accidental formatting and zeroizes its owned allocation on drop, but it
is not key custody or secure memory.
Step 2: Attach the container and extract a service
use rullst::server::{Extension, Json, post};
use rullst::{Router, di::Inject};
pub async fn process_payment(
Inject(user_service): Inject<UserService>,
) -> Json<&'static str> {
// Use a bounded provider adapter; never log the key or full request payload.
let _configured = user_service.gateway.is_configured();
Json("Payment request accepted")
}
let container = configure_di()?;
let app = Router::new()
.route("/payments", post(process_payment))
.layer(Extension(container));
The continuation is application code because its error type and provider
adapter belong to the project. Inject<T> takes T from the container and,
when the container lacks it (or there is none), from an Extension<Arc<T>>
layer. If neither provides it, Inject<T> fails closed with a 500 whose body
is a fixed message; the missing type name is logged on the rullst::di
target, never returned to the client.
Key takeaways
- Services are registered as type-safe
Arc<T>singletons. - DI controls construction and lookup; it does not provide authorization, transaction boundaries, or secret management.
- Prefer ordinary constructors when a small service graph does not benefit from a container.
Tutorial 29: IoT data and frame helpers (rullst-iot)
rullst-iot provides telemetry/state models and protocol frame builders that
compile without std. Some APIs use alloc, so a bare-metal application must
supply an allocator.
It also provides bounded MQTT 5 PUBLISH (MqttPublish) and CoAP request
(CoapRequest) encoders. The crate does not currently read hardware registers
or provide MQTT/CoAP network transport, OPC-UA, Sparkplug B, HSM, or
post-quantum implementations.
Experimental.
rullst-iotand themake:iotgenerator are in the Experimental maturity tier: the API may change between 13.x releases and nothing has been validated on physical hardware. The module status lists which modules are working implementations and which are only data models or contracts.
Add the crate
In a Rullst application, cargo rullst make:iot TemperatureSensor turns on the
iot feature of rullst, writes src/iot/temperature_sensor.rs (a
TemperatureSensorDevice that returns SensorTelemetry readings) and declares
the iot modules. The crate is then reachable as rullst::iot, so the
rullst_iot:: paths below become rullst::iot::.
A firmware crate depends on rullst-iot directly and turns off its default
std feature:
[dependencies]
rullst-iot = { version = "=13.0.0-alpha.4", default-features = false }
Build a telemetry model and Modbus request
#![allow(unused)]
fn main() {
use rullst_iot::{AnomalyDetector, ModbusFrame, SensorTelemetry};
let telemetry = SensorTelemetry::new(
"sensor-01",
"temperature",
38.5,
1_700_000_000,
);
let detector = AnomalyDetector::new(25.0, 5.0);
let state = detector.evaluate(telemetry.value);
// This builds bytes for a request. Platform code must send them over a real
// serial/TCP transport and handle timeouts, retries, and the response.
let request = ModbusFrame::read_holding_registers(1, 0, 10);
assert_eq!(request.len(), 8);
let _ = state;
}
Signed firmware artifacts
Use OtaManifest and OtaManager::new_with_trusted_key to verify a firmware
artifact before selecting an inactive partition. See the
crate guide for the trust, persistence, and bootloader
requirements that remain the integrator’s responsibility.
Experimental fixtures
The opt-in experimental-simulators feature contains explicitly named
Simulated* fixtures. They are deterministic test data generators, not hardware
or protocol implementations.
Tutorial 30: Axum Escape-Hatch Snapshot (cargo rullst eject) 🔓
eject generates a minimal Axum/Tokio entry point that can begin a manual
migration away from Rullst’s server wrapper. It does not statically expand
macros, copy the application’s route graph, convert middleware, or remove Rullst
dependencies automatically.
Step 1: Generate a separate starting point
cargo rullst eject
The default output is src/ejected_main.rs; the existing src/main.rs remains
unchanged. Cargo does not build src/ejected_main.rs (it is not a binary
target): review it and compare it with src/main.rs; only --force (Step 2)
replaces the entry point. The generated template serves a placeholder route
from application_routes() and wraps it in the configured Rullst security
baseline, the same apply_security_baseline step rullst::Server performs
with Rullst.toml. Axum is reached through the rullst::web::axum re-export,
so the template compiles without adding Axum to Cargo.toml. Without its
header comment and start-up message, the template is:
use rullst::RullstConfig;
use rullst::web::axum::{Router, response::Html, routing::get};
use std::net::SocketAddr;
/// Mount the application's routes here.
fn application_routes() -> Router {
Router::new().route("/", get(|| async { Html("<h1>Ejected Axum Server</h1>") }))
}
#[rullst::runtime::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = if std::path::Path::new("Rullst.toml").exists() {
RullstConfig::load_from_file("Rullst.toml").await?
} else {
RullstConfig::default()
};
let environment = config.environment()?;
let app = rullst::apply_security_baseline(application_routes(), config.security, environment)?;
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let listener = rullst::async_runtime::tokio::net::TcpListener::bind(addr).await?;
rullst::web::axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
The baseline covers secure headers, CORS, WAF/RASP and CSRF (headers, WAF and CSRF in staging/production). Static files, rate limiting, lifecycle probes, hot reload, sessions, authentication and authorization are not included.
Move application routes into application_routes() one bounded group at a
time and retain equivalent limits, telemetry, graceful shutdown, health
behavior, state, and authorization tests.
Step 2: Treat --force as a deliberate replacement
cargo rullst eject --force
The hardened command first preserves the original entry point as
src/main.rs.rullst-backup and refuses to overwrite an existing backup. The
template does not carry over the original routes or module declarations; move
them from the backup before building or deploying. Keep a
normal version-control commit as the authoritative recovery path. Custom output
paths are restricted to relative Rust files under src/ and existing targets
are not overwritten implicitly.
Key takeaways
- Ejection is a migration aid, not a semantics-preserving compiler transform.
- The application remains responsible for dependency cleanup and replacements for ORM, auth, queues, Studio, Nexus, Capital, AI, and other selected crates.
- Run
cargo fmt, strict Clippy, the complete test suite, and application security/operational checks after every migrated route group.
Tutorial 31: SaaS deployment preparation for AWS or GCP
This guide prepares a generated SaaS application for a cloud deployment. It is not an end-to-end production certification: identity, network policy, database operation, secrets, billing and recovery remain deployment responsibilities.
1. Materialize and verify the SaaS starter
Install the published 13.0.0-alpha.4 CLI with its exact version (Cargo never
selects a prerelease on its own, and the v12 line reached end of life on
16 October 2026), then generate deterministically. Do not substitute the
moving main branch for an immutable release artifact:
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
cargo rullst new my_cloud_saas --default --blueprint saas --docker
cd my_cloud_saas
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
Review every generated route, access policy, migration and environment placeholder before supplying live credentials.
2. Supply secrets outside source control
Use AWS Secrets Manager, Google Secret Manager or an equivalent deployment
boundary for values such as APP_KEY, DATABASE_URL, provider keys, webhook
secrets and, if you use encrypted model fields, RULLST_ENCRYPTION_KEY. Do not
bake .env into an image (the generated .dockerignore keeps .env and
.env.* out of the build context).
Set RULLST_ENV=production and make startup fail when a required production
adapter or credential is missing. Empty and mock_* provider credentials are
for deterministic offline development, not live operation.
3. Build and scan the exact image
docker build --pull --tag my-cloud-saas:<git-sha> .
docker inspect my-cloud-saas:<git-sha>
Pin the deployed image by digest. Run the application’s tests and a container
scanner against the exact candidate. The CLI’s deploy command writes a
Dockerfile, compose.yaml and Caddyfile for a Linux VM (see
Deploy to a Linux VM); AWS App Runner/ECS and Google
Cloud Run configuration remains explicit cloud work.
4. Configure the cloud boundary
For AWS or GCP, define and review:
- private database connectivity, TLS and least-privilege credentials;
- trusted proxy handling and the application’s external origin policy;
- ingress authentication, rate limits, body limits and request timeouts;
- readiness/liveness behavior and a bounded shutdown grace period;
- immutable image rollout and a tested rollback procedure;
- logs, metrics and alerts that avoid secrets and unnecessary PII.
Managed services have provider-specific scaling floors, quotas, cold starts and costs. Verify current provider documentation and load-test your selected region and topology rather than assuming scale-to-zero or a request rate.
5. Exercise stateful recovery
Before production traffic, rehearse migrations, backup, restore, webhook replay, field-encryption key rotation, a failed rollout and database unavailability. Health endpoints only report the checks implemented by the application; they do not create zero-downtime deployment by themselves.
Tutorial 32: Bounded security telemetry in Studio
Rullst’s honeypot, RASP and Studio security page provide local defense-in-depth signals. They are not an autonomous SOC, a universal blocker, an AI incident responder or a durable SIEM integration.
1. Compose local request controls
rullst::Server already applies secure headers and the WAF in staging and
production. This example composes the rullst-security layers on a plain Axum
router:
#![allow(unused)]
fn main() {
use axum::Router;
use rullst_security::{CspSecurityLayer, HoneypotLayer, HoneypotState, RaspSecurityLayer};
use std::net::SocketAddr;
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let app = Router::new()
// Add application routes first.
.layer(RaspSecurityLayer)
.layer(CspSecurityLayer)
.layer(HoneypotLayer::new(HoneypotState::default()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
}
ConnectInfo supplies the accepted socket peer used by the honeypot. Deployments
behind a proxy must establish and test a trusted client-identity boundary; the
middleware deliberately does not trust arbitrary forwarding headers.
2. Understand each signal
- Honeypots match configured exact synthetic paths and keep bounded, expiring process-local bans.
- RASP performs bounded heuristic inspection for selected patterns and can have false positives and negatives.
- CSP and headers depend on the final rendered page, proxy, browser and TLS deployment.
SecurityStoreis local telemetry. An event is not HMAC-verified or durably delivered merely because it appears in Studio.
No built-in path asks an LLM to ban a peer or automatically mounts a blocking
policy. The opt-in ThreatSentinel can classify three bounded,
caller-supplied aggregate patterns and issue an HMAC-authenticated,
subject-bound, expiring, one-shot process-local proof-of-work challenge.
The application must supply trustworthy observations and subject identity,
translate the outcome into an HTTP protocol, and decide where that gate belongs.
It is not AI attribution, a distributed replay store, or an autonomous ban.
Sensitive automated actions still require authenticated policy, limits,
durable audit and human approval where appropriate.
3. Inspect the local Studio view
In a debug build the generated starters start Studio in the same process with
rullst::studio::run_studio(5555), which serves only loopback peers
(LocalStudioAccess::loopback_only()). Open
http://127.0.0.1:5555/studio/security. The page renders this process’s
current SecurityStore telemetry and keeps unavailable sources visibly
unavailable. A multi-instance deployment needs a
shared, authenticated event pipeline with retry, acknowledgement, retention and
dead-letter handling before it can be described as an operational SIEM.
See the Threat Radar and SOC guide and the v12 security evidence ledger for exact boundaries.
Tutorial 33: Auth-bound multi-tenancy
Rullst Core can select a tenant from a request hint only after application
authentication has inserted a trusted TenantMembership. A hostname, header,
query parameter or request body is never sufficient proof of membership.
1. Configure the selection layer
#![allow(unused)]
fn main() {
use axum::{Extension, Router, routing::get};
use rullst_core::{
multitenant::{TenantConfig, TenantLayer, TenantStrategy},
security::TenantContext,
};
async fn tenant_dashboard(Extension(tenant): Extension<TenantContext>) -> String {
format!("Selected workspace: {}", tenant.tenant_id)
}
fn tenant_routes() -> Router {
let selection = TenantLayer::new(TenantConfig::new(TenantStrategy::Subdomain));
Router::new()
.route("/dashboard", get(tenant_dashboard))
.layer(selection)
}
}
An outer, application-owned authentication layer must first validate the
session/token and insert TenantMembership::try_new(...) from trusted identity
claims. Without that extension, TenantLayer returns 403 Forbidden. If the
requested subdomain is not in the authenticated membership set, it also returns
403. A request that names no tenant (and, with the subdomain strategy, has no
with_domain_fallback) uses the membership’s default
(TenantMembership::with_default, or the only tenant of a single-tenant
membership) and is refused with 403 when there is none. In Axum, remember that
subsequently added layers run first; test the final middleware order
in-process.
TenantStrategy::Header and TenantStrategy::Parameter are also available,
but they remain untrusted selection hints. Query parameters additionally leak
more easily through history, referrers and access logs. The built-in subdomain
parser reads the Host header, or the HTTP/2 :authority when no Host is
sent, and selects the first label only for hostnames with at least three
labels; a www label is never a tenant. When the apex domain sits under a
multi-label public suffix such as .com.br or .co.uk, set
TenantConfig::with_base_domain("escola.com.br") (added in v13): the tenant is then the
label immediately to the left of that domain, and the apex itself uses
with_domain_fallback (which only the subdomain strategy reads). Custom-domain
ownership and trusted-proxy normalization remain application and deployment
work.
2. Bind every database query
Tenant selection does not rewrite arbitrary SQL or automatically add a tenant
predicate to every ORM query. Bind the authenticated tenant explicitly. This
example uses SQLx directly; its #[derive(FromRow)] needs SQLx’s derive
feature in your manifest, which Rullst does not enable:
#![allow(unused)]
fn main() {
use rullst_core::security::TenantContext;
use sqlx::{FromRow, PgPool};
#[derive(FromRow)]
struct Invoice {
id: i64,
tenant_id: String,
total_minor: i64,
}
async fn list_tenant_invoices(
pool: &PgPool,
tenant: &TenantContext,
) -> Result<Vec<Invoice>, sqlx::Error> {
sqlx::query_as::<_, Invoice>(
"SELECT id, tenant_id, total_minor FROM invoices WHERE tenant_id = $1",
)
.bind(&tenant.tenant_id)
.fetch_all(pool)
.await
}
}
Use database constraints and, where appropriate, database-native row-level security as additional defense. Negative tests must prove that one tenant cannot read, update or delete another tenant’s records through every relevant route, repository, background job and administrative surface.
3. Operational boundaries
TenantContextis server-selected state, not a claim accepted from JSON.current_tenant_id()is task-local convenience; passing an explicitTenantContextto domain/storage APIs is easier to audit.TenantCache,TenantStorage,TenantRealtimeandTenantPresenceprovide validated namespaces, but applications still own business authorization and remote provider policy.- Cross-process membership updates, custom-domain verification, database policy, audit retention and incident response are not automatic framework guarantees.
Tutorial 34: Live Analytics Dashboard (rullst::live & WebSockets) 📈
Build a per-connection analytics view with rullst::live. Feed it values from
your own authoritative metrics source; the example below uses explicit state
only to demonstrate the component lifecycle.
🛠️ Step 1: Create Analytics LiveComponent
cargo rullst make:live AnalyticsDashboard writes
src/live/analytics_dashboard.rs and declares it in src/live/mod.rs; declare
mod live; in the crate root yourself. Replace the generated counter with:
#![allow(unused)]
fn main() {
use rullst::async_trait;
use rullst::live::LiveComponent;
use serde_json::Value;
#[derive(Default)]
pub struct AnalyticsDashboard {
pub revenue_mrr: f64,
pub active_users: usize,
}
#[async_trait]
impl LiveComponent for AnalyticsDashboard {
async fn mount(&mut self) {
// Replace these initial values with an application-owned metrics query.
self.revenue_mrr = 0.0;
self.active_users = 0;
}
async fn handle_event(&mut self, payload: Value) {
if let Some(event) = payload.get("event").and_then(|v| v.as_str()) {
if event == "refresh" {
self.revenue_mrr += 100.00;
self.active_users += 1;
}
}
}
fn render(&self) -> String {
format!(
r#"<div id="analytics-dashboard" class="p-8 bg-slate-900 text-white rounded-2xl shadow-2xl border border-slate-800">
<h2 class="text-2xl font-bold mb-6">Live Analytics Stream</h2>
<div class="grid grid-cols-2 gap-6 mb-6">
<div class="p-4 bg-slate-800 rounded-xl">
<p class="text-slate-400 text-sm">MRR</p>
<p class="text-3xl font-mono text-emerald-400">${:.2}</p>
</div>
<div class="p-4 bg-slate-800 rounded-xl">
<p class="text-slate-400 text-sm">Active Users</p>
<p class="text-3xl font-mono text-cyan-400">{}</p>
</div>
</div>
<button ws-send name="event" value="refresh" class="px-6 py-3 bg-indigo-600 hover:bg-indigo-500 font-semibold rounded-xl">
⚡ Refresh Live Stream
</button>
</div>"#,
self.revenue_mrr, self.active_users
)
}
}
}
💻 Step 2: Render in View Page
use rullst::html::Markup;
use rullst::live::Live;
use crate::live::analytics_dashboard::AnalyticsDashboard;
pub async fn analytics_page() -> Markup {
Live::mount::<AnalyticsDashboard>("/ws/analytics").await
}
Live::mount pre-renders the component inside a wrapper that opens the
WebSocket; return it as the page or interpolate it into an html! layout.
Register the matching upgrade route on the application’s rullst::Router
(generated projects reach Axum only through rullst::web::axum):
let app = router()
.ws("/ws/analytics", rullst::live::live_ws_handler::<AnalyticsDashboard>);
The page must also load HTMX and a pinned HTMX WebSocket extension; Rullst does not inject that browser dependency.
💡 Key Takeaways
- Rullst owns the server-side component lifecycle; a browser transport is still required.
- The current implementation re-renders an HTML fragment after each valid JSON event. It does not provide distributed state, replay, authorization or a metrics source automatically.
- Incoming events are limited to 64 KiB per WebSocket message; larger messages close the session.
Tutorial 35: Performance measurement and resilient operation
Performance is a property of a concrete application, build, host and workload. Rullst includes Criterion microbenchmarks and production helpers, but does not promise a universal latency, throughput or availability number.
1. Build an application-specific release binary
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "debuginfo"
Generated projects already ship a release profile with lto = "thin",
codegen-units = 1 and strip = "symbols"; strip = "debuginfo" keeps symbol
names for production backtraces. cargo rullst build compiles the release
binary and pre-compresses static assets.
Alternative linkers such as mold or lld can improve developer link time on
supported hosts. target-cpu=native can improve a host-specific binary but
makes it less portable; do not put it in a generally distributed artifact
without an explicit CPU baseline.
2. Configure and measure the database pool
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
async fn configure() -> Result<(), rullst_orm::Error> {
Orm::init_with_options(
"postgres://app:[email protected]/app",
20, // maximum connections
10, // acquire timeout in seconds
)
.await?;
Ok(())
}
}
Call it before Server::run: the server keeps an already-initialized pool
instead of opening its own from DATABASE_URL. Choose limits from database
capacity and measured concurrency. More connections can increase contention and
resource use instead of increasing throughput.
3. Run reproducible benchmarks
In a checkout of the Rullst repository, run one group at a time with the command the benchmark workflow uses for it, for example:
cargo bench -p rullst-core --bench core_bench
cargo bench -p rullst-orm --features drivers-all --bench orm_bench
Record at least:
- exact commit, Rust version, features and release profile;
- CPU, memory, operating system and power/virtualization settings;
- database/provider version and topology;
- warm-up, sample count, concurrency and payload distribution;
- median and tail latency, throughput, errors and resource use.
Criterion microbenchmarks detect regressions in selected functions. They do not model a production network, database, proxy or user journey. For cross-framework comparisons, follow the method in the v13 framework comparison plan.
The public benchmark hub links the eight published groups: facade/HTTP, Core primitives, ORM, Auth, Connect, Security, AI and Capital. Nine Criterion binaries back those groups because ORM has both local and cross-ORM inputs. A crate without a microbenchmark is not automatically less mature: protocol vectors, generated-project compilation, restart/failure contracts, no_std target builds or whole-request tests can fit its dominant risk better. Add a new benchmark only with a stable operation, input, unit and interpretation.
The repository also contains a cross-ORM SQLite harness:
cargo bench -p rullst-orm --features strict-sqlite \
--bench orm_comparison
It pins Diesel and SeaORM in the lockfile and gives all three ORMs one typed SQLite connection, separate database files, the same schema/unique index, 100 equivalent rows and identical WAL/synchronous/busy-timeout policy. Criterion measures primary-key lookup, indexed filtered lookup, count, ordered list-ten and insert/delete. This makes the input inspectable; it does not make the architectures identical. Diesel’s synchronous call path and the async Rullst/SeaORM executor paths remain part of what is measured.
The benchmark workflow runs it and publishes the results with the other groups. Read them as regression/comparison evidence for these five SQLite shapes only, and never generalize them to networked PostgreSQL/MySQL, concurrency, memory use or complete applications.
4. Test failure behavior separately
Availability requires deployment exercises: health/readiness semantics, timeouts, overload, cancellation, database failover, backup/restore, migration rollback and secret rotation. Miri, sanitizers, fuzzing and Kani are target-specific correctness tools; none measures availability or proves the absence of every memory error.
36. Assisted framework upgrades
Rullst v12 introduced a bounded upgrade transaction for existing applications. The goal is to make the safe, repeatable part a single command while refusing to guess about application data or security policy.
The transaction steps below were written for
12.1.0; check publication status. The v12 line reached end of life on 16 October 2026, so a v12 application moves to v13 with the v13 CLI. The v13 source rules and the rule classification are described in v12 to v13: source findings and belong to the13.0.0-alpha.4prerelease. Install the exact release and complete the application-specific validation below before any production rollout.
What the command can guarantee
cargo rullst upgrade can inventory the Cargo workspace, update the Rullst
release train, apply compiler suggestions, require cargo check, and restore
the files it controls after a failure. It cannot prove that a database upgrade,
authorization rule, provider integration or deployment still behaves correctly.
The automatic transaction owns only:
- versioned Rullst dependencies in exact Cargo workspace manifests;
- the root
Cargo.lockproduced by Cargo resolution; - Rust edits proposed by
cargo fix; - a
cargo check --workspace --all-targets --lockedgate using the application’s selected features.
The 12.1 updater writes managed requirements as =VERSION, preserving the
explicitly selected release instead of allowing Cargo to choose a later patch
or minor version. cargo fix resolves the candidate lockfile, and the final
check must use that resolution unchanged. Review these pins before restoring
any broader dependency-update policy in your application.
It never runs migrations, changes secrets, invents tenant/ownership policy, opens Nexus or Studio, contacts application providers, or marks the result production-ready.
1. Prepare the application
Create a branch, make the worktree reviewable, record the old test result, and back up every database. Prove the database backup can be restored before changing the framework.
Install the exact CLI from the same release train as the target framework. For a v12 application, that is the v13 prerelease (Cargo never selects a prerelease on its own):
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked --force
The framework command does not update its own executable. This matters for v5:
the already-published v5 CLI cannot gain the v12 migration engine
retroactively, and the v13 CLI upgrades only from v12 or later. A v5
application first moves to v12 with the end-of-life 12.x CLI
(cargo install cargo-rullst --version 12.1.0 --locked --force, as the steps
below show), then to v13. Run each CLI inside the application.
2. Inspect without writing
cargo rullst upgrade --dry-run
The plan shows every dependency edit and source finding. MUST-CHANGE
(BLOCKER in the 12.x CLI) means a known old API or option requires a source
change; REVIEW means the code uses an API, feature or configuration whose
behaviour changed and must be revalidated. Neither label means that unreported
code is automatically safe.
For automation, request JSON:
cargo rullst upgrade --dry-run --json > upgrade-plan.json
The root object uses schema_version: "rullst.upgrade-plan.v1" and identifies
the rule catalog, exact target, manifest changes, detected source majors,
findings, automatic scope and mandatory manual gates. Consumers must reject an
unknown schema version rather than silently interpreting it as v1. The v13 CLI
extends v1 additively (see the v13 fields); consumers must
ignore keys they do not know.
Use an explicit target to make the selected published version visible:
cargo rullst upgrade --to 13.0.0-alpha.4 --dry-run
Other targets must exist in the registry and belong to the installed CLI’s major train, so a CLI never applies rules it does not carry: a v12 CLI cannot plan a v13 migration, and the v13 CLI cannot downgrade a project.
3. Apply the transaction
After resolving or accepting every finding:
cargo rullst upgrade
Before the first write, the CLI snapshots Cargo workspace manifests, the root lockfile and Rust sources under:
target/rullst-upgrades/<UTC-run-id>/
├── files/
├── index.tsv
├── report.md
└── report.json
It then edits the TOML while preserving comments and relative order, runs compiler-provided fixes and executes the Cargo check gate. A failing gate restores the controlled files automatically and returns a non-zero status.
To deliberately keep a partial result for diagnosis:
cargo rullst upgrade --keep-on-failure
To restore a persisted snapshot after that mode or after an interrupted run:
cargo rullst upgrade \
--restore target/rullst-upgrades/<UTC-run-id>
Restore accepts only a path-validated snapshot inside the current project’s
target/rullst-upgrades directory. cargo clean deletes target, so retain a
normal version-control commit or copy a needed diagnostic report before
cleaning.
Recovery boundaries and 12.1.0 hardening
Stop editors, watchers and other writers before restoring. File recovery does
not undo build-script/test side effects or database/external-service changes.
Keep an independent version-control backup; the directory under target is not
a substitute for one. A filesystem error during application can leave some
files restored and others unchanged, so always review the result.
Since 12.1.0 the CLI preflights the entire backup, stages every replacement before writing originals, and rejects linked or malformed paths. It limits indexes to 8 MiB/100,000 entries, each snapshot to 64 MiB and the total to 512 MiB. Disk failure while staging leaves originals intact; failure during the later per-file apply reports progress and retains the backup. This is not an all-files atomic commit and does not defend against hostile concurrent filesystem changes. These improvements are not in 12.0.0.
4. Finish a v5 to v12 migration
This step uses the v12 CLI. The v13 CLI accepts only v12 and v13 projects, so upgrade older applications to v12 first and then to v13.
The v5 README used attribute-style routing and a server builder with no router or port. The scanner reports these markers instead of applying a global text replacement. Replace the old shape:
#[routes]
fn home() -> Response {
// ...
}
Server::new()
.route("/", get(home))
.run()
.await;
with an explicit v12 router and typed error propagation:
use rullst::{Server, response::Html, routes};
async fn home() -> Html<&'static str> {
Html("Hello from v12")
}
#[rullst::runtime::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = routes![get("/" => home)];
Server::new(app).run(3000).await?;
Ok(())
}
Then follow the complete v5 → v12 guide, including feature selection, disposable database migration/rollback, explicit Nexus and Studio boundaries, provider validation and authorization negatives.
5. Run the application-owned gates
At minimum:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo build --release
Also test the actual production feature set, restore/migrate/rollback against a production-shaped database copy, cross-user and cross-tenant denials, proxy/TLS identity, CSRF/CORS, Nexus/Studio exposure and every configured live provider.
v12 to v13: source findings
The v13 CLI carries the rullst-upgrade-rules-v4 catalog. It parses the
application instead of matching text: Rust files with syn (so comments, doc
comments and string literals never match an API rule, and macro bodies such as
html! and routes! are read token by token), Cargo manifests with a TOML
parser, and a few generated project files (.gitignore, .dockerignore,
.cargo/config.toml, Foundry.toml, docker-compose.prod.yml, Kubernetes
YAML, an Omni shell under omni-app/, rullst-client.ts, Dockerfile,
.env/.env.example keys and release scripts). Files are read without
following symbolic links; a finding records a rule and a line, never file
contents. A Rust file that does not parse, is not UTF-8 or is larger than
2 MiB is listed as NOT SCANNED.
Each finding has a stable code (V13-...), a kind, a location, a one-line
message and the first-column title of its row in the
v13 migration guide:
Source findings (rullst-upgrade-rules-v4): 1 must-change, 2 review
MUST-CHANGE src/views/home.rs:12 [V13-HTML-DYNAMIC-EVENT-HANDLER] a dynamic value in an `on*`/`hx-on*` attribute of `html!` no longer compiles
migration-v13 row: `html!` event handlers
REVIEW src/main.rs:64 [V13-RENDER-PAGE-LANGUAGE] `render_page` declares `lang="pt-BR"`; English pages should use `render_page_with_lang`
migration-v13 row: Starter page language
- must-change: a compile-breaking or API-shape change that the code uses
(for example a dynamic
onclick={...}inhtml!, abelongs_towithoutforeign_key, a struct-level#[sqlx(rename_all)]on a model, amatchonFieldKindwithout a wildcard, a utoipa 5 dependency used with Studio). - review: the code uses an API, feature or configuration whose behaviour
changed (for example
Model::all(),ValidatedJson, theMailfacade,render_page, a generated.gitignorewithout the SQLite journal files).
A review rule is reported once per file (its first location); a must-change
rule at every location. The rule classification
lists which migration rows have rules. Findings never block cargo rullst upgrade, whose
cargo check gate still restores the project when the code does not compile.
The gate’s error names the must-change findings and repeats the guidance of
V13-CAPITAL-REMOVED and V13-MAIL-REMOVED findings, which name removed APIs
but are review findings.
update project verify, review and apply refuse a preparation that has
must-change findings and carry review findings in the plan.
Rules by release
Each rule records the release that introduced the change it reports. The rules for the 12.x to 13 changes date from 13.0.0-alpha.1; a rule added later, or reworded because a later release changed what it reports, carries that release:
| Release | Rules |
|---|---|
| 13.0.0-alpha.1 | The other 95 rules |
| 13.0.0-alpha.2 | V13-HTML-MARKUP-RETURN, V13-HTML-RAW-MARKUP, V13-ORM-CONSTRAINT-ERRORS, V13-TEXT-TIMESTAMPS, V13-POLYGLOT-FEATURE |
| 13.0.0-alpha.3 | V13-CONNECT-GITHUB-EMAIL, V13-ORM-TRANSACTION-ERRORS, V13-ORM-SKIP-MANAGED-COLUMNS, V13-ORM-DEFAULT-FEATURES (reworded: rullst-orm has no default driver), V13-RULLST-DEFAULT-FEATURES, V13-OMNI-REMOVED, V13-OFFLINE-SYNC-REMOVED |
| 13.0.0-alpha.4 | V13-DEPLOY-TARGETS-REMOVED, V13-DEPLOY-GENERATORS-REMOVED, V13-CLI-SELF-UPDATE-REMOVED, V13-CLI-AI-REMOVED, V13-ORM-FROM-ROW, V13-SQLX-MACROS, V13-NEXUS-AI-FEATURE, V13-SQLX-TLS-RING |
A rule is dated by the first release tag whose catalog contains its code
(git show v13.0.0-alpha.2:cargo-rullst/src/generators/build/upgrade/rules/catalog.rs,
or git log -S'"V13-..."' on that file), checked against the CHANGELOG entry
that names it; a new rule takes the next release. A unit test checks that every
rule names one of these releases.
The plan takes the application’s Rullst version to be the oldest version that
a Rullst requirement in the workspace manifests admits (=13.0.0-alpha.3,
or 13.0.0-alpha.1, a caret requirement) or that Cargo.lock holds. When that
version is a 13.0 prerelease, only the rules introduced after it apply: an
alpha.3 application gets the alpha.4 rules, an alpha.2 application the seven
alpha.3 rules and the alpha.4 rules, and an alpha.1 application the alpha.2,
alpha.3 and alpha.4 rules, because the earlier changes are already in the
version it runs. The plan says what it skipped:
Source findings (rullst-upgrade-rules-v4): 0 must-change, 1 review
Rules: introduced after 13.0.0-alpha.2, the Rullst version this project uses; earlier rules skipped: 100 (findings hidden: 3); --all-rules applies every rule
A v12 application, or one whose version cannot be read (path dependencies
without a version and no Cargo.lock), gets every rule. --all-rules
applies every rule to a prerelease application too, for example to review the 12.x to 13 changes again. Project
preparations (cargo rullst update) always apply every rule, because their
must-change findings gate execution.
JSON plan fields
--dry-run --json keeps every rullst.upgrade-plan.v1 key. Each element of
source_findings keeps path, line, code (the stable rule id), severity
(BLOCKER or REVIEW) and message, and adds:
| Key | Meaning |
|---|---|
kind | must-change or review |
introduced_in | The release that introduced the rule’s change, such as 13.0.0-alpha.3 |
migration_row | First-column title of the row in migration-v13.md |
migration_url | The published table of the v13 migration guide |
The root object adds finding_counts (must_change, review),
unscanned_sources (project-relative paths), migration_guide and
rule_selection: current_version (the application’s Rullst version, or
null when it cannot be read), all_rules (--all-rules, and always true
in project preparations), skipped_rules (rules introduced in or before
current_version that were not applied) and hidden_findings (the findings
those rules would have added). A consumer that ignores unknown keys reads the
plan as before; findings of skipped rules are absent from source_findings
and finding_counts.
Fixing the findings
Each finding names its rule and the first-column title of its row in the
v13 migration guide,
which says what changed and how to adapt. Fix them by hand, or with your
editor’s or terminal’s AI assistant given the plan, the migration row and the
project’s AGENTS.md (cargo rullst generate:ai-context); review every edit
it proposes. cargo rullst ai upgrade, which proposed such fixes in earlier
13.0 prereleases, was removed in 13.0.0-alpha.4.
A practical order:
cargo rullst upgrade --dry-runand read every finding with its migration row.- Fix findings that also compile against v12 (most review items and the
html!, ORM and Nexus must-change items), then run the v12 tests. cargo rullst upgrade. When a fix needs a v13 API (for example utoipa 6 with Studio), runcargo rullst upgrade --keep-on-failure, fix the kept state, and finish withcargo rullst upgrade(no further version edits; it validates the result).- Run the application-owned gates below.
Rehearsal on generated starters
A rehearsal generates starters with a published CLI, points their Rullst
dependencies at a checkout of the next version through [patch.crates-io]
(the registry requirements stay, so the upgrade edits them as it would for a
published release), runs cargo rullst upgrade with the newer CLI and then
each project’s build, tests and Clippy.
13.0.0-alpha.2 to 13.0.0-alpha.3
The October 2026 rehearsal used the published v13.0.0-alpha.2 Linux binary,
checked with sha256sum -c cli-checksums-x86_64-unknown-linux-gnu.txt from the
same release, and a CLI built from main; the projects built with Rust
1.99.0. Because main still carried the alpha.2 version, the rehearsal
exported it (git archive), set every package to 13.0.0-alpha.3 in that
copy, patched the projects to the copy and passed --to 13.0.0-alpha.3. The
published alpha.3 CLI targets its own version, so --to is not needed there.
./cargo-rullst-13.0.0-alpha.2-x86_64-unknown-linux-gnu rullst new a2-saas --default \
--blueprint saas --database sqlite --skip-initial-migration
cargo build && cargo test # on 13.0.0-alpha.2; commit Cargo.lock
# append [patch.crates-io] entries for every rullst package in Cargo.lock, commit
cargo rullst upgrade --dry-run --to 13.0.0-alpha.3
cargo rullst upgrade --to 13.0.0-alpha.3
# edit what the findings ask for, then
cargo build && cargo test && cargo clippy --all-targets -- -D warnings
Blank, Blank --api, SaaS and LMS starters built on alpha.2 without warnings,
and 3, 3, 7 and 11 tests passed. Against the alpha.3 candidate:
| Starter | Must-change | Review | cargo rullst upgrade | Manual edits | alpha.3 build, tests and Clippy |
|---|---|---|---|---|---|
| Blank | 0 | 0 | passed; changed only Cargo.toml and Cargo.lock | none | no warnings; 3 passed; clean |
Blank --api | 0 | 1 (V13-HOT-RELOAD-MACHINE-ENDPOINTS) | passed; changed only Cargo.toml and Cargo.lock | none | no warnings; 3 passed; clean |
| SaaS | 0 | 4 (V13-ORM-SKIP-MANAGED-COLUMNS; V13-REFERRER-NO-REFERRER in two files, V13-MAIL-FACADE-CONFIG) | passed; changed only Cargo.toml and Cargo.lock | #[orm(skip)] to #[orm(db_default)] on User::is_admin and User::session_epoch | no warnings; 7 passed; clean |
| LMS | 0 | 6 (V13-ORM-SKIP-MANAGED-COLUMNS; V13-LMS-RECORD-PROGRESS in three files, V13-REFERRER-NO-REFERRER, V13-MAIL-FACADE-CONFIG) | passed; changed only Cargo.toml and Cargo.lock | the same User edit | no warnings; 11 passed; clean |
V13-ORM-SKIP-MANAGED-COLUMNS is the only finding about an alpha.3 change.
The other findings locate APIs whose behaviour changed between 12.x and
13.0.0-alpha.2, so they also appear on a new alpha.2 or alpha.3 starter and
need no edit in an application that already runs alpha.2. After the User
edit the SaaS and LMS dry runs report 3 and 5 findings, all of that kind.
The rehearsal’s CLI applied every rule; the plan now skips the rules an
alpha.2 application already runs with (see Rules by release),
so its default dry run lists only V13-ORM-SKIP-MANAGED-COLUMNS for SaaS and
LMS and nothing for Blank and Blank --api, and --all-rules lists the
findings in the table.
An HTTP smoke test of each upgraded starter (db:migrate, then the server in
development) passed. In SaaS and LMS, registration stored is_admin = 0 and
session_epoch = 0 from the column defaults, “Sign out everywhere” raised the
epoch and the earlier cookie was refused, signing in again worked and the
loopback Nexus answered 200. Blank and the JSON API answered / and
/health, and the API’s bearer POST /api/messages returned 201 (401
without the token).
The same starters generated by the alpha.3 CLI differ from the alpha.2 ones
only in the User fields above, in the localized account mail and APP_NAME
of the password controller and .env and the checked reset-link lifetime of
src/models/password_reset.rs (the “Account mail language and product name”
and “Generated reset link lifetime” rows; existing projects keep their code)
and in the SaaS users migration, which imports through rullst::db instead of
rullst_orm (both compile). The
alpha.2 to alpha.3 checklist
turns this into steps for a real application.
Limits: SQLite only; the starters use none of the other APIs that alpha.3
removes or changes (Omni, offline-sync, default features, Orm::transaction,
mock Connect credentials), so their rules ran only in the catalog’s unit
tests; no live mail, billing or OAuth provider was called.
Earlier rehearsals: v12 to v13
.github/rehearse-v12-upgrade.sh is a manual helper (not a CI job) for the
v12 to v13 path; its header documents the arguments. In October 2026 it and
the published 12.3.0 binary upgraded Blank, Blog, SaaS and LMS starters of the
12.0.0, 12.1.2 and 12.3.0 CLIs to the 13.0.0-alpha.1 workspace with review
findings only (5 to 14 per starter, for example V13-GITIGNORE-DATABASES,
V13-MODEL-ALL and V13-HEALTH-PROBES) and no source edit, with one
exception: the 12.3 SaaS starter still names LemonSqueezyProvider, so
cargo fix failed and the project was restored until the Lemon Squeezy
branches of src/controllers/billing_controller.rs and the BILLING_STORE_ID
lines of .env and .env.example were removed, as V13-CAPITAL-REMOVED
describes (that edit also compiles on 12.3). Starters of the 12.0 CLI also
carry the build files
listed in the migration guide. Those counts predate the alpha.2 and alpha.3
rules and were not re-measured.
v12 → v13 rule classification
Every row of the v13 migration guide
is classified for rullst-upgrade-rules-v4:
- (a) must-change: a compile-breaking or API-shape change detectable in application source (12 rows);
- (b) review: changed behaviour worth reviewing when the application uses the affected API, feature or configuration, which the rules locate (101 rows);
- (c) none: no application impact, or not detectable in the application (CLI behaviour, generator output for new projects, opt-in features, fixes of inputs that previously failed) (102 rows).
A row with both a must-change and a review part counts in both. The catalog has 115 rules. A row can map to several rules and a rule to several rows; when a row changes in a later release, update this table and the rule together.
| Migration row | Class | Rules | Notes |
|---|---|---|---|
| Existing runtime APIs | (c) none | — | General statement; the session-schema migration is the next row |
| Account/session registry | (b) review | V13-AUTH-RECOVERY-MIGRATE | |
| Private object storage | (c) none | — | Opt-in storage-s3 candidate |
| R2 public URLs | (b) review | V13-R2-PUBLIC-URL | |
| Tenant local storage | (c) none | — | Only a tenant directory below a regular file changes the error; not located |
| Recoverable Live UI | (c) none | — | Opt-in module |
| Distributed tracing | (b) review | V13-OTLP-ENVIRONMENT | |
| Core queue | (b) review | V13-QUEUE-SEMANTICS | Unindexed 12.x Redis failures are not located |
| Core scheduler, cache and realtime | (b) review | V13-PRESENCE-COUNTING | The TenantCache key encoding is not located |
| Core memory cache sweeps | (c) none | — | Internal sweep cadence; reads unchanged |
| Studio queue monitor | (b) review | V13-QUEUE-DRIVER-PREVIEWS | |
| Studio API playground | (a) must-change | V13-STUDIO-UTOIPA-6 | Must-change when a manifest declares utoipa < 6 or utoipa-axum < 0.3 |
| Studio local access and table view | (b) review | V13-STUDIO-TABLE-VALUES | |
| LMS blueprint | (c) none | — | Generator output; existing applications keep their code |
| Consumer generators | (c) none | — | Generator commands |
html! event handlers | (a) must-change | V13-HTML-DYNAMIC-EVENT-HANDLER | |
html! returns Markup | (a) must-change | V13-HTML-MARKUP-RETURN, V13-HTML-RAW-MARKUP | Located in function signatures and RawHtml calls, including html! bodies; a String collected from html! output, an #[island] call or Live::mount output is reported where RawHtml wraps it |
| Core request validation | (b) review | V13-VALIDATION-STATUS | |
| Core feature flags and scheduler | (b) review | V13-SCHEDULER-WEEKDAYS | A/B splits: the two feature-flag rules below; the bucket reassignment depends on flag configuration and is not located |
| Memory feature-flag splits | (b) review | V13-MEMORY-FEATURE-SPLITS | |
| Database feature-flag splits | (b) review | V13-DB-FEATURE-SPLITS | |
| Security headers | (b) review | V13-REFERRER-NO-REFERRER | |
| Core WAF signatures | (c) none | — | Only requests that previously failed with 403 change |
| Core security header precedence | (c) none | — | Explicit header values in handlers and inner layers are not located |
| AI provider streaming and stop reasons | (b) review | V13-AI-GEMINI-STOP-REASONS | |
| AI image-beacon guardrail | (c) none | — | Guardrail hardening without an application API change |
| AI RAG tenant tags | (b) review | V13-AI-RAG-TENANT-TAGS | |
| Anthropic provider output | (b) review | V13-AI-ANTHROPIC-OUTPUT | |
| OpenAI provider output | (b) review | V13-AI-OPENAI-OUTPUT | |
| Ollama host | (c) none | — | Only values that previously failed change meaning |
| AI chat memory keys on MySQL/MariaDB | (b) review | V13-AI-CHAT-MEMORY-KEYS | The database backend is not located |
| Core CSRF on HEAD | (c) none | — | HEAD previously failed with 500 |
| Hot-reload machine endpoints | (b) review | V13-HOT-RELOAD-MACHINE-ENDPOINTS | |
| Core JSON PII masking | (b) review | V13-PII-MASKING | |
| Core PII masking of range responses | (b) review | V13-PII-MASKING | |
| Security DLP, RASP and honeypot | (b) review | V13-SECURITY-DLP-HONEYPOT | |
| Security audit log lines | (b) review | V13-SECURITY-AUDIT-LOG-LINES | |
| Connect generic OIDC | (b) review | V13-OIDC-OPTIONAL-NAME | |
| Age assurance | (c) none | — | Opt-in APIs and generator |
| Optional consent and export | (c) none | — | Opt-in generator |
| Project context | (c) none | — | CLI generator |
| Typed API generation | (c) none | — | Opt-in CLI profile |
| CLI generator output | (b) review | V13-TS-CLIENT-REGENERATE | Other generator changes affect new output only |
| CLI generator robustness | (b) review | V13-TS-CLIENT-REGENERATE, V13-GITIGNORE-DATABASES | Other generator changes affect new output only |
| ORM database-managed columns | (b) review | V13-ORM-SKIP-MANAGED-COLUMNS | Reports #[orm(skip)] on is_admin/session_epoch without #[sqlx(skip)], as alpha.2 starters declared them |
| ORM dirty tracking | (c) none | — | Opt-in RowSnapshot field |
| ORM partial updates | (b) review | V13-ORM-PARTIAL-UPDATE | |
| ORM derive checks | (a) must-change | V13-ORM-BELONGS-TO-KEY, V13-ORM-IGNORED-RELATION-KEY, V13-ORM-SEARCHABLE-TABLE | |
| ORM queries and cache | (b) review | V13-ORM-SQLX-JSON-SERIALIZE, V13-ORM-QUERY-CACHE-KEY | Count, chunk-order and stream changes are not located |
| ORM query-cache index | (b) review | V13-ORM-CACHE-PREFIX | |
| ORM Redis model hashes | (b) review | V13-ORM-REDIS-HASHES | |
| ORM outbox keys on MySQL/MariaDB | (b) review | V13-OUTBOX-MYSQL-KEYS | The database backend is not located |
| ORM audit payloads on MySQL/MariaDB | (b) review | V13-ORM-AUDIT-PAYLOADS | Auditable models and audit-table setup; the database backend is not located |
| Turso migrations | (b) review | V13-TURSO-ROLLBACK-DRIFT | |
| Schema table names on PostgreSQL | (b) review | V13-SCHEMA-PG-TABLE-CASE | |
Legacy SecretString ciphertext | (c) none | — | Older values become readable; no source change |
SecretString client input | (b) review | V13-SECRET-STRING-CLIENT-INPUT | |
ORM protected values and SecretString serialization | (b) review | V13-ORM-PROTECTED-VALUES | Encrypted and masked model fields, and SecretString fields of serialized structs and models |
| Offline Redis mock | (b) review | V13-REDIS-MOCK-TIE-ORDER | |
| SQLite DSN paths | (c) none | — | Only stray files of earlier versions |
| Auto-healing diagnostics | (b) review | V13-AUTO-HEALING-DIAGNOSTICS | |
| ORM migrations | (c) none | — | Existing migrations keep their behaviour; db:seed refusing pending migrations is command behaviour |
| ORM transactional migrations and the pool | (c) none | — | within_transaction() is a v13 API, so v12 migrations are unaffected |
| ORM instance mutations | (b) review | V13-ORM-MISSING-ROW | Plain delete() calls are not located |
| ORM encryption key settings | (c) none | — | Only .env entries that the process environment lacks change; .env is not read |
| ORM constraint errors | (b) review | V13-ORM-CONSTRAINT-ERRORS | Code that matches DatabaseError |
| ORM transaction errors | (b) review | V13-ORM-TRANSACTION-ERRORS | Orm::transaction calls in files that mention DatabaseError; cargo check reports closure error types without From<rullst_orm::Error> |
| ORM query values | (c) none | — | Only exhaustive matches on RullstValue break, and cargo check reports them |
| ORM date/time columns | (b) review | V13-TEXT-TIMESTAMPS | String created_at/updated_at fields of SQLx models; whether the table’s migration calls timestamps() is not checked |
| Schema builder constraints | (c) none | — | Only Column struct literals break, and cargo check reports them |
| ORM sandbox tests | (a) must-change | V13-ORM-SANDBOX-TEST | Flags every sandbox test as review; cargo check reports the signatures that no longer compile |
| ORM model SQLx options | (a) must-change | V13-ORM-SQLX-STRUCT-OPTION | |
| ORM trashed scopes | (b) review | V13-ORM-ONLY-TRASHED | |
| ORM bulk soft deletes | (b) review | V13-ORM-TRASHED-DELETE-ALL | |
| ORM joined scopes | (b) review | V13-ORM-SQL-TEXT | |
| ORM chunk bounds | (b) review | V13-ORM-CHUNK-BOUNDS | |
| ORM eager pivot order | (c) none | — | The order was unspecified before |
| PersonalData reports | (b) review | V13-PERSONAL-DATA-REPORT | |
| Nexus derive | (a) must-change | V13-NEXUS-PRIMARY-KEY, V13-NEXUS-FIELD-KIND-NUMBER | The field-kind rule is a review |
| Nexus composite keys | (c) none | — | Opt-in composite keys; only RegistryEntry struct literals break, and cargo check reports them |
| Nexus dates and times | (c) none | — | Derived field kinds change without a source edit; comparisons with FieldKind::DateTime are not located |
| Nexus access in the starters | (c) none | — | Generator output; alpha.2 #[orm(skip)] fields are reported under ORM database-managed columns |
| Nexus credentials | (b) review | V13-NEXUS-DOTENV | |
| Nexus panel | (a) must-change | V13-NEXUS-FIELD-KIND-MATCH | Other panel changes are runtime behaviour without an application API |
| CLI migration catalog | (c) none | — | CLI |
CLI upgrade downgrades | (c) none | — | CLI |
| CLI Foundry service environment | (b) review | V13-FOUNDRY-SERVICE | |
| CLI Foundry build artifact | (b) review | V13-FOUNDRY-SERVICE | |
| CLI generators | (b) review | V13-MFA-CLIENT-SECRET | Other changes affect new generator output only |
| CLI operations | (b) review | V13-FOUNDRY-SERVICE | Script exit statuses are not located |
| CLI diagnostics and side effects | (c) none | — | CLI |
| CLI home screen | (c) none | — | CLI |
| CLI security audit | (c) none | — | CLI |
| CLI dashboard audit | (c) none | — | CLI |
| Core deferrable jobs | (c) none | — | Opt-in APIs |
| Core scheduler time zones | (c) none | — | Opt-in scheduler-tz feature |
| Dash N+1 warning | (c) none | — | CLI |
| CLI security report | (c) none | — | CLI |
| Generated security tests | (c) none | — | Generator output for new projects |
| Blank JSON API starter | (c) none | — | Generator output; existing API projects keep their behaviour |
| CLI audit lockfile | (c) none | — | CLI |
| RASP shell signatures | (c) none | — | Request matching; not detectable in source |
| CLI footprint report | (c) none | — | New CLI command; renamed green (CLI ai removed, footprint renamed green row) |
CLI dev/dash shutdown | (c) none | — | CLI |
CLI dev --ts-sync | (c) none | — | CLI |
CLI dash live metrics | (c) none | — | CLI |
| Core development telemetry | (b) review | V13-DEV-TELEMETRY-ROUTE | |
| Core development error console | (c) none | — | Development-only page; handle_autofix callers are not located |
CLI ai fix and ai review | (c) none | — | Added and removed within the 13.0 prereleases |
| CLI project updates on SELinux | (c) none | — | CLI |
CLI pkg in a virtual workspace | (c) none | — | CLI |
CLI new repository | (c) none | — | CLI |
CLI generated validator dependency | (c) none | — | Generator output; cargo check reports a missing validator dependency |
CLI make:model imports | (c) none | — | Generator output for new models |
CLI dockerize and generate:buildah | (c) none | — | Affected generated files are not distinguishable |
| CLI errors and exit codes | (c) none | — | CLI output and scripts |
CLI doctor | (c) none | — | CLI |
| CLI output additions | (c) none | — | CLI |
| SaaS plan gates | (c) none | — | Opt-in generated module |
| Capital providers and NFS-e removed | (b) review | V13-CAPITAL-REMOVED | Compile-breaking; reported for review because a name such as FiscalError can also be an application type; also locates BILLING_PROVIDER=lemonsqueezy and BILLING_STORE_ID in .env and .env.example |
| Capital quota keys on MySQL/MariaDB | (b) review | V13-CAPITAL-QUOTA-KEYS | SqlQuotaStore/SqlQuotaBackend; the database backend is not located |
| Capital zero tier limit | (b) review | V13-CAPITAL-ZERO-TIER | |
| Capital provider subscription IDs | (c) none | — | Rejects dot-only identifiers before a request |
| Capital invoice amounts | (c) none | — | Only amounts below one minor unit change |
| Messaging outbox relay key | (b) review | V13-OUTBOX-RELAY-KEY | |
| Messaging encrypted SQLite startup | (c) none | — | Correct keyrings are unaffected |
| Messaging Redis Streams candidate | (c) none | — | No 12.x release contains the adapter |
| Mail providers removed | (b) review | V13-MAIL-REMOVED | Compile-breaking; also locates removed MAIL_DRIVER values and settings in Rust strings, .env, .env.example and Rullst.toml |
| Mail driver default | (b) review | V13-MAIL-FACADE-CONFIG | |
| Mail sender | (b) review | V13-MAIL-FACADE-CONFIG | |
| Mail attachment inspection | (b) review | V13-MAIL-ATTACHMENT-INSPECTION | |
| Mail link checks | (c) none | — | Depends on message content |
| Mail facade settings | (b) review | V13-MAIL-FACADE-CONFIG | |
| Mail queued attachments | (b) review | V13-MAIL-QUEUED-ATTACHMENTS | Mail::init_queue, Mail::enqueue/enqueue_for_tenant and register_mail_handler |
| Mail SES addresses | (c) none | — | Requests SES rejected now succeed |
| Mail Resend scheduling | (b) review | V13-MAIL-RESEND-SCHEDULE | |
| Mail tracking recipient | (b) review | V13-MAIL-TRACKING-RECIPIENT | |
| Mail plain-text fallback | (b) review | V13-MAIL-TEXT-FALLBACK | |
| Labs runner | (a) must-change | V13-LABS-RUNNER-REMOVED | |
| Education candidates | (c) none | — | New v13 packages, first published in 13.0.0-alpha.1 |
| Labs package | (c) none | — | New package in the release inventory |
| Media S3 storage | (c) none | — | New opt-in feature of a package first published in 13.0.0-alpha.1 |
| Generated billing settings | (b) review | V13-BILLING-PROJECT-SETTINGS | |
| Application templates | (c) none | — | Generator output |
| Starter migrations | (b) review | V13-SQLITE-ONLY-SEED-TIME | |
| ERP starter access | (b) review | V13-ERP-DASHBOARD-ACCESS | |
| LMS lesson progress | (b) review | V13-LMS-PROGRESS-KEY | |
| LMS concurrent progress saves | (b) review | V13-LMS-RECORD-PROGRESS | |
| LMS lesson media in Nexus | (b) review | V13-LMS-MEDIA-KINDS | |
| Blog robots and sitemap | (b) review | V13-BLOG-ROBOTS-SITEMAP | |
| Generated login and registration | (b) review | V13-CREDENTIAL-RATE-LIMIT | |
| SaaS user JSON | (b) review | V13-PASSWORD-HASH-HIDDEN | |
| Registration timestamps | (b) review | V13-EMPTY-TIMESTAMPS | |
| Billing row timestamps | (b) review | V13-EMPTY-TIMESTAMPS | |
| Registration lengths | (b) review | V13-UTF16-LENGTHS | |
| Revocable login sessions | (c) none | — | Additive APIs and generator output |
| Password change and reset | (c) none | — | Additive APIs and generator output |
| VPS deploy proxy | (b) review | V13-VPS-DEPLOY-PROXY | |
| Blank database status | (b) review | V13-MODEL-ALL | |
| Portfolio profile | (b) review | V13-PORTFOLIO-PROFILE | |
| Blog and Portfolio pages | (b) review | V13-PAGE-CSP-NONCE | |
| Generated linker configuration | (b) review | V13-LINKER-CONFIG | |
| Build files from the 12.0 CLI | (b) review | V13-MSVC-FASTLINK, V13-CARGO-LOCK-IGNORED, V13-DOCKER-UNLOCKED-BUILD | Projects generated by the 12.0 CLI |
| ERP orders and stock | (b) review | V13-ERP-STORE-ORDER | |
| Blog and ERP reads | (b) review | V13-MODEL-ALL | |
| Blog and ERP page bounds | (b) review | V13-PAGE-BOUNDS | |
| Docker build context | (b) review | V13-DOCKER-CONTEXT | |
| Kubernetes Ingress | (b) review | V13-K8S-INGRESS-TLS | |
| Starter health probes | (b) review | V13-HEALTH-PROBES | |
| Kubernetes and Buildah names | (b) review | V13-K8S-NAMES | |
| Pre-compressed static assets | (c) none | — | CLI |
| Static asset caching | (c) none | — | Runtime default; not detectable in source |
| Generated release profile | (c) none | — | Generator output for new projects |
| Generated container runtime | (c) none | — | Generator output for new projects |
Interactive new --api | (c) none | — | CLI |
CLI new wizard | (c) none | — | CLI |
| Minimal default features | (b) review | V13-RULLST-DEFAULT-FEATURES, V13-ORM-DEFAULT-FEATURES | Reports rullst and rullst-orm dependencies that keep their defaults and select no driver |
Generated rullst-orm dependency | (c) none | — | Generator output; such a dependency is reported under Minimal default features |
| ORM adapter aggregate features | (a) must-change | V13-POLYGLOT-FEATURE | Dependency features and [features] entries such as "rullst/orm-polyglot" |
| Omni and offline sync removed | (a) must-change, (b) review | V13-OFFLINE-SYNC-REMOVED, V13-OMNI-REMOVED | The feature in dependency features and [features] entries; the shell’s omni-app/tauri.conf.json or omni-app/src/lib.rs, and cargo rullst omni/make:omni in workflows, root scripts, Makefile and justfile |
| Starter page language | (b) review | V13-RENDER-PAGE-LANGUAGE | |
| Repository examples | (c) none | — | Repository examples, not applications |
| Windows paths in CLI and console output | (c) none | — | CLI output |
| Consumer scaffolds under symlinked directories | (c) none | — | CLI |
| Core HSTS default | (c) none | — | Runtime default; not detectable in source |
Artisan and .env | (c) none | — | std::env::var calls in migrations and seeders are not located |
| Core WAF in development | (c) none | — | Development-only behaviour |
| CSRF token on static-looking paths | (c) none | — | Only requests that previously received no token change |
| Multipart CSRF refusal | (c) none | — | Only the refusal text changes; tests that compare it are not located |
| Secure cookies over HTTPS | (c) none | — | Runtime behaviour; not detectable in source |
| CSRF cookie on cacheable responses | (c) none | — | Cache-Control values set by handlers are not located |
| Connect mock credentials | (c) none | — | Credentials come from configuration and are not located |
| Connect mock authorization URL | (c) none | — | Mock mode only |
| Account mail language and product name | (c) none | — | Generator output; existing projects keep their code |
| Production log defaults | (c) none | — | Runtime defaults; not detectable in source |
| Connect GitHub email | (b) review | V13-CONNECT-GITHUB-EMAIL | GithubProvider and Connect::driver("github", ...) |
| Account mail link lifetime | (c) none | — | Only reset lifetimes that previously failed change; the link wording is not located |
| Generated reset link lifetime | (c) none | — | Generator output; existing projects keep their code |
| CLI deploy targets removed | (b) review | V13-DEPLOY-TARGETS-REMOVED | fly.toml, render.yaml, railway.json, railway.toml, and deploy --platform fly|railway|render in workflows, root scripts, Makefile and justfile; introduced in 13.0.0-alpha.4 |
| SQLite database in the deployed image | (c) none | — | Generator output for new Dockerfiles; an existing Dockerfile without DATABASE_URL is not located, and cargo rullst deploy warns when it keeps one; introduced in 13.0.0-alpha.4 |
| CLI Foundry, Nix, Buildah and gRPC generators removed | (b) review | V13-DEPLOY-GENERATORS-REMOVED | Foundry.toml, a nixify flake.nix, build_buildah.sh, src/grpc/mod.rs, and the removed commands in workflows, root scripts, Makefile and justfile; introduced in 13.0.0-alpha.4 |
| CLI self-update removed | (b) review | V13-CLI-SELF-UPDATE-REMOVED | rullst update check|verify|stage|install, or update guided with --scope/--root, in workflows, root scripts, Makefile and justfile; introduced in 13.0.0-alpha.4 |
CLI ai removed, footprint renamed green | (b) review | V13-CLI-AI-REMOVED | rullst ai or rullst footprint in workflows, root scripts, Makefile and justfile; introduced in 13.0.0-alpha.4 |
| Core error console assistant panel | (c) none | — | Development-only page; handle_autofix response readers are not located; introduced in 13.0.0-alpha.4 |
| CLI starters moved to the examples | (c) none | — | CLI behaviour; applications generated from the removed starters keep their code; introduced in 13.0.0-alpha.4 |
| ORM derive dependencies | (c) none | — | Fix of code that previously failed to compile; a tracing or tokio dependency kept for the derive still works; introduced in 13.0.0-alpha.4 |
ORM FromRow and SQLx macros | (a) must-change | V13-ORM-FROM-ROW, V13-SQLX-MACROS | Models deriving FromRow beside Orm (any path, separate derive attributes included) unless #[orm(custom_from_row)]; SQLx derives on other structs and enums and sqlx::…::query!-family macros when no manifest enables SQLx’s derive/macros (or its default features); introduced in 13.0.0-alpha.4 |
| Nexus AI assistant feature | (b) review | V13-NEXUS-AI-FEATURE | A rullst dependency with nexus/nexus-tz but not nexus-ai, or rullst-nexus without default features and without ai; introduced in 13.0.0-alpha.4 |
| Generated starter dependencies | (c) none | — | Generator output for new projects; an unused tracing-subscriber in an existing project keeps working; introduced in 13.0.0-alpha.4 |
| TLS crypto provider | (b) review | V13-SQLX-TLS-RING | A sqlx dependency (any manifest of the workspace, [workspace.dependencies] included) or a [features] entry that enables tls-rustls, tls-rustls-ring or tls-rustls-ring-webpki, reported once per manifest; tls-rustls-ring-native-roots is not located, because its aws-lc-rs replacement trusts different roots; the runtime provider defaults need no change; introduced in 13.0.0-alpha.4 |
CLI auth on database starters | (c) none | — | CLI behaviour on new runs; earlier generated files are unchanged; introduced in 13.0.0-alpha.4 |
CLI --no-database files | (c) none | — | Generator output for new projects; an empty rullst.db is not located; introduced in 13.0.0-alpha.4 |
CLI make:migration:auto without a database to compare | (c) none | — | CLI exit status; scripts that run it are not located; introduced in 13.0.0-alpha.4 |
| Internals hidden from the API documentation | (c) none | — | The items still compile; only their documentation and compatibility promise change; introduced in 13.0.0-alpha.4 |
Planned simpler update experience
Since 13.0.0-alpha.4 the CLI never downloads or replaces its own binary.
Install a newer CLI with Cargo and its exact version
(cargo install cargo-rullst --version =VERSION --locked). At most once a day,
in an interactive terminal, the CLI prints that command when crates.io has a
newer release of its major; see the
CLI reference.
The 12.1.0 to 13.0.0-alpha.3 self-updater (update check, update verify,
update stage and update install) was removed; the project commands below
remain.
cargo rullst update project prepare --project PATH --json
copies the Git working directory into private storage and edits only the
candidate’s versioned workspace dependencies. It preserves dirty and untracked
source, tracked deletions and the root lockfile, including a legacy ignored
lockfile. Compare before/ and candidate/ at the reported location and review
preparation.json. Builds/tests are not executed and neither execution nor
application is authorized. See the preparation limits and exclusions.
Use update project verify --prepared PATH --dry-run to inspect its validation
commands. Only after reviewing the trusted project, use --allow-project-code
to authorize lockfile resolution, locked checks and tests in a fresh private
copy. This is not a sandbox; tests inherit your environment and can have
external effects. See the verification options and limits.
Then update project review --verified PATH --json revalidates the verification
record and shows the full dependency diff with a review digest. Use the returned
verified_directory; the digest grants no apply authority. Explicit
update project apply --verified PATH --approved-review SHA256 applies only
the reviewed manifests/root lockfile. The matching recover command restores
only that operation and refuses unrelated edits.
The safe-update priority
proposes one guided flow for project preparation, validation and approved
application. cargo rullst update guided --to VERSION --project PATH composes
those commands. Each approval defaults to no and follows its complete review.
Version, directories and digests are carried between steps; separate prompts
govern trusted project execution/network and original-file application.
--offline keeps verification local. Since 13.0.0-alpha.4 the flow no longer
installs a CLI (--scope and --root were removed): install the target CLI
with Cargo first. See the guided command.
File recovery does not replace database backups or application acceptance
tests, and updating the CLI alone never updates a deployed application.
The 12.1.0 update mechanism does not implement v13 migrations. The
v13 CLI (13.0.0-alpha.4 prerelease) carries its own rule catalog
(v12 to v13: source findings); update guided
offers a project update only within the installed CLI’s major.
Is this unique?
No. Assisted upgrades are an established framework practice: Rails documents
interactive bin/rails app:update, Angular provides ng update, and Dart
offers preview/apply analysis fixes through dart fix. Microsoft’s .NET
Upgrade Assistant also analyzed and changed projects, although Microsoft now
marks it deprecated in favor of its modernization tooling.
Rullst’s useful distinction is the bounded composition: Cargo-workspace-aware TOML edits, a version-selected framework rule catalog, human and JSON plans, controlled snapshots, default rollback, explicit recovery and Cargo gates in one CLI flow. This is a testable design choice, not evidence that Rullst is the first or universally the best updater.
Official references:
Polyglot Persistence
Important
This page describes the v13 prerelease source, and its dependency examples pin the published
13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and a plain"13.0.0-alpha.4"would also accept later alphas (see the v13 migration guide). The v12 line (rullst@12) reached end of life on 16 October 2026; see the release record. CommitCargo.lockfor reproducible builds, and use path dependencies only for checkout-local review.
Rullst keeps explicit relational and specialized persistence contracts. SQLx Active Record supports SQLite, PostgreSQL, MySQL and MariaDB. The Blank starter can instead use Turso/libSQL as its primary database, through a separate typed adapter. MongoDB is for documents, DuckDB for embedded OLAP/analytics, SurrealDB for documents plus bounded read-only graph queries, Qdrant for dense vector search and Redis for selected native structures. No optional adapter silently changes the application’s primary database.
Only the SQLx backends work with the #[derive(Orm)] query builder, Schema,
artisan!, Server::with_db, Nexus and Studio’s database pages. The
Database Support table lists, backend by backend, what
works, which cargo rullst new starters accept it and which CI test runs it
against a real server.
Choose the smallest feature
Using the umbrella crate, next to the primary database profile:
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["strict-postgres", "orm-mongodb"] }
Available umbrella features are orm-turso, orm-mongodb, orm-duckdb,
orm-surrealdb, orm-qdrant and orm-redis. Direct rullst-orm users select
turso, mongodb, duckdb, surrealdb, qdrant or redis. There is no
feature that enables them all: 13.0 removed the polyglot and orm-polyglot
aggregates, so list the adapters the application uses.
The examples below use rullst_orm::… paths, as projects generated by
cargo rullst new do: they depend on rullst-orm directly, which
#[derive(Orm)] requires because it expands to rullst_orm::… paths. Code
that does not derive models can use the same items through the umbrella crate
as rullst::orm::….
Features are additive. Prefer one adapter unless the application genuinely uses several, and inspect the final dependency graph with:
cargo tree -e features
The project wizard first asks for a primary backend. SQLite, PostgreSQL, MySQL and MariaDB use SQLx; Turso is offered as the primary database of the Blank starter only, and its choice states what it does not integrate with. The wizard then offers the side stores, each described as one. For a hybrid SQLx application, use one or more explicit flags:
cargo rullst new edge_app --default --database mariadb --turso --mongodb \
--qdrant --skip-initial-migration
MariaDB shares the SQLx MySQL protocol implementation but has its own live
container contract. --turso is additive; --database turso is the explicit
primary selection and currently rejects the SQLx-specific SaaS starter.
Turso / libSQL as the primary ORM
Turso-primary is an alternative to SQLx, not another SQLx backend. It works
through TursoOrm, TursoModel, TursoRepository and TursoMigration, and
does not integrate with Schema, artisan!, Server::with_db, Nexus or
Studio’s database pages; #[derive(Orm)] generates the separate TursoModel
API described below.
cargo rullst new edge_api --default --api --database turso
cd edge_api
cargo rullst make:model Event --migration
cargo rullst db:migrate
The generated manifest enables orm-turso without a direct SQLx dependency,
and the environment contains TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, and
TURSO_OFFLINE_PATH rather than a fictitious DATABASE_URL. Models use the
same public derive with an explicit backend:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "events", backend = "turso")]
struct Event {
id: i64,
label: String,
active: bool,
}
}
Initialize once before using the generated inherent methods:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "events", backend = "turso")]
struct Event {
id: i64,
label: String,
active: bool,
}
async fn use_turso_model() -> Result<(), Box<dyn std::error::Error>> {
rullst_orm::polyglot::TursoOrm::init_from_env().await?;
let mut event = Event {
id: 0,
label: "started".into(),
active: true,
};
event.save().await?;
let current = Event::find(event.id).await?;
let _ = current;
Ok(())
}
}
TursoOrm::init_from_env reads TURSO_DATABASE_URL, TURSO_AUTH_TOKEN and
TURSO_OFFLINE_PATH from the process environment only. The Turso blank starter
(cargo rullst new --database turso) instead reads them with
rullst::config::project_setting, which falls back to ./.env without loading
it into the process, and passes them to TursoConfig::new and
with_offline_path; since 13.0.0-alpha.2 it no longer calls
dotenvy::dotenv() or depends on dotenvy.
The typed contract includes CRUD, equality filters, ordering, bounded
pagination/counts, app-assigned or generated primary keys, checksummed
migrations, status, and rollback. An equality filter whose value encodes as
NULL (such as Option::None) matches with IS NULL. #[orm(encrypted)]
columns store randomized ciphertext, so filtering or ordering on them returns
an error instead of silently matching nothing. Generated make:model and make:migration
commands retain the Turso backend. It does not yet provide SQLx ORM relations,
hooks, automatic timestamps, seed generation, schema auto-diff, or transparent
embedded-replica synchronization. Those limits are why only the blank/API
starter is currently advertised as Turso-primary. The derive rejects
unsupported SQLx-specific model behaviors at compile time instead of silently
ignoring their attributes.
Turso / libSQL explicit edge SQL
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
TursoConfig, TursoMigration, TursoQueryLimit, TursoStatement, TursoStore,
TursoValue,
};
async fn use_edge_sql() -> Result<(), Box<dyn std::error::Error>> {
let config = TursoConfig::new(
std::env::var("TURSO_DATABASE_URL").unwrap_or_default(),
std::env::var("TURSO_AUTH_TOKEN").unwrap_or_default(),
);
let edge = TursoStore::connect(config).await?;
let create = TursoStatement::new(
"CREATE TABLE events (id INTEGER PRIMARY KEY, label TEXT NOT NULL)",
vec![],
)?;
edge.migrate(vec![TursoMigration::new("m20260829_events", vec![create])?])
.await?;
edge.execute(TursoStatement::new(
"INSERT INTO events VALUES (?1, ?2)",
vec![TursoValue::Integer(1), TursoValue::Text("started".into())],
)?).await?;
let rows = edge.query(
TursoStatement::new(
"SELECT id, label FROM events WHERE id = ?1",
vec![TursoValue::Integer(1)],
)?,
TursoQueryLimit::new(100)?,
).await?;
let _ = rows;
Ok(())
}
}
The live path speaks the official Hrana HTTP v3 protocol directly through the
workspace’s Rustls-backed HTTP client; it does not embed the native SQLite
engine or the legacy remote SDK dependency chain. URLs must use
libsql:// or HTTPS; cleartext HTTP requires an explicit loopback-development
opt-in. Requests reject redirects and have a 30-second deadline. Tokens and
endpoint details are redacted from Debug; remote responses are capped at
16 MiB, result materialization at 1–10,000 rows, each statement at 1,024
positional parameters/8 MiB of parameter data, and each transaction at 1,024
statements/16 MiB of raw SQL plus parameters. SQL result cells can still be
large, so applications must also constrain selected columns and data at the
schema/query boundary.
An empty or mock_* endpoint selects a single-connection SQLite in-memory
fallback, persisted to a file with TursoConfig::with_offline_path
(TursoOrm::init_from_env uses TURSO_OFFLINE_PATH, default
turso-development.db). It executes real SQL and migrations deterministically
but does not simulate remote replication, latency, failover, or Turso Cloud.
The live CI contract runs the same API against the official sqld container
and proves that a failed multi-statement batch rolls back its earlier writes.
The portable document contract
MongoDB, SurrealDB, and the deterministic offline store implement the same bounded document operations:
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
CollectionName, DocumentId, DocumentPage, DocumentRepository,
};
fn bounded_document_inputs() -> Result<(), Box<dyn std::error::Error>> {
let collection = CollectionName::new("audit_events")?;
let id = DocumentId::new("event-2026-0001")?;
let page = DocumentPage::new(0, 50)?;
let _ = (collection, id, page);
Ok(())
}
}
Collection names use a portable ASCII identifier grammar, IDs are bounded to
letters, digits, _ and -, and every list requires a 1–500 row page. Models
must serialize as objects and must not own the driver ID field (_id for
MongoDB, id for SurrealDB); the portable DocumentId owns that concern.
Encrypted export and crash-resumable restore
MongoDB, SurrealDB and the deterministic store also implement
DocumentInventory<T>, which retains each validated identifier. That enables
one deliberately conservative recovery path without pretending the engines
share transactions:
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
CollectionName, DocumentRecoveryBinding, DocumentRecoveryKey,
DocumentRecoveryPolicy, export_document_snapshot,
restore_document_snapshot,
};
async fn recovery<S, D, Event>(source: &S, destination: &D) -> Result<(), Box<dyn std::error::Error>>
where
S: rullst_orm::polyglot::DocumentInventory<Event>,
D: rullst_orm::polyglot::DocumentInventory<Event>,
Event: serde::Serialize + serde::de::DeserializeOwned + Send + Sync,
{
let collection = CollectionName::new("audit_events")?;
let binding = DocumentRecoveryBinding::try_new(
"my_application.production",
collection,
)?;
let key = DocumentRecoveryKey::try_new(
"recovery-2026-09",
[42_u8; 32], // load 32 random bytes from a secret manager
)?;
let policy = DocumentRecoveryPolicy::try_new(100, 10_000, 16 * 1024 * 1024)?;
// Persist this opaque envelope in application-owned durable storage.
let snapshot = export_document_snapshot(source, &binding, &key, policy).await?;
let report = restore_document_snapshot(
destination,
&snapshot,
&binding,
&key,
policy,
).await?;
assert_eq!(report.verified(), report.inserted() + report.replayed());
Ok(())
}
}
The key uses AES-256-GCM and a fresh nonce; its authenticated data binds the
rotation ID, trusted application namespace and exact collection. Key material
passes through a zeroizing temporary and key/snapshot Debug output is
redacted. The policy permits pages of 1–500, 1–100,000 documents and a 1 KiB–64
MiB plaintext ceiling. Models in this portability path must be JSON objects
without either id or _id.
Export scans twice and refuses to seal unequal observations. This detects ordinary concurrent changes, but it is not a database snapshot isolation primitive: pause writers or use a source-side transaction/export facility. Restore accepts only an empty destination or a matching subset left by an earlier attempt. It inserts without replacement, rejects different or extra rows before mutation and verifies an exact final inventory. A failed attempt can retain earlier successful inserts, so retry the same authenticated snapshot. Keep unrelated destination writers paused until verification. The destination database, namespace and collection/table must already be provisioned where the engine requires them; schema or permission failures stay visible and are never reclassified as an empty destination.
The live release matrix exercises MongoDB → SurrealDB → MongoDB. It proves the bounded adapter contract on that runner, not backup retention, key custody, replication, point-in-time recovery, topology failover or vendor operations.
MongoDB
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
CollectionName, DocumentId, DocumentRepository, MongoDbStore,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct AuditEvent {
action: String,
}
async fn store_audit_event() -> Result<(), Box<dyn std::error::Error>> {
let store = MongoDbStore::<AuditEvent>::connect_or_mock(
std::env::var("MONGODB_URL").unwrap_or_default(),
"my_application",
).await?;
store.create(
&CollectionName::new("audit_events")?,
&DocumentId::new("event-1")?,
&AuditEvent { action: "login".into() },
).await?;
Ok(())
}
}
An empty URL or one beginning with mock_/mock:// selects an in-process,
deterministic BTreeMap store. A non-mock URL uses the official MongoDB Rust
driver. The release contract includes an optional Testcontainers CRUD suite;
availability, backups, indexes, topology and authorization remain deployment
responsibilities.
SurrealDB documents and graph reads
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
GraphQuery, GraphRepository, SurrealAuth, SurrealConfig, SurrealDbStore,
};
async fn read_graph() -> Result<(), Box<dyn std::error::Error>> {
let config = SurrealConfig::new(
std::env::var("SURREALDB_URL").unwrap_or_default(),
"main",
"application",
SurrealAuth::bearer(
std::env::var("SURREALDB_TOKEN").unwrap_or_default(),
),
);
let store = SurrealDbStore::<serde_json::Value>::connect_or_mock(config)?;
let query = GraphQuery::read_only(
"MATCH (person:person)-[knows:knows]->(friend:person) RETURN friend",
100,
)?;
let rows = store.query_graph(&query).await?;
let _ = rows;
Ok(())
}
}
The adapter uses SurrealDB’s documented HTTP /key, /sql, /rpc and /gql
protocol instead of embedding its BSL-licensed SDK. replace runs a typed
UPDATE type::record($table, $id) through /rpc rather than PUT /key,
which upserts: replacing a missing (for example concurrently deleted) document
returns PolyglotError::NotFound instead of recreating it, as on MongoDB. This
statement uses SurrealDB 3 function naming. It disables redirects,
requires HTTPS outside loopback unless cleartext is explicitly enabled, redacts
authentication in Debug, streams through a configurable 1 KiB–8 MiB memory
ceiling, and sends namespace/database headers on every scoped request.
Every DocumentId is stored as a string record key, including all-digit IDs
such as 9 (sent as a quoted key; SurrealDB would otherwise parse them as
numbers). Inventory pages therefore follow the portable string order, 10
before 9, on SurrealDB as on MongoDB. Records written by earlier versions
under an ID that SurrealDB parsed as a number, NULL or an exponent (for
example 9, null or 1e5) keep that non-string key and are not found by
these string IDs; rewrite them under their string key when upgrading.
Graph queries must start with MATCH; semicolons, caller-provided LIMIT, and
the INSERT, SET, REMOVE, and DELETE tokens are rejected. The adapter
then appends its own 1–1,000 row limit. This is a conservative read boundary,
not arbitrary SurrealQL execution or a graph mutation API. The /gql endpoint
requires SurrealDB 3.2 or newer and explicit experimental enablement on the
3.2 release line; the live contract pins 3.2.4 and enables that capability.
DuckDB analytics
#![allow(unused)]
fn main() {
use rullst_orm::polyglot::{
AnalyticsRepository, AnalyticsValue, DuckDbStore, QueryLimit,
};
async fn analyze_events() -> Result<(), Box<dyn std::error::Error>> {
let analytics = DuckDbStore::in_memory().await?;
analytics.execute(
"CREATE TABLE events (sequence BIGINT, label VARCHAR)",
vec![],
).await?;
analytics.execute(
"INSERT INTO events VALUES (?, ?)",
vec![
AnalyticsValue::Signed(1),
AnalyticsValue::Text("started".into()),
],
).await?;
let rows = analytics.query(
"SELECT sequence, label FROM events WHERE sequence >= ?",
vec![AnalyticsValue::Signed(1)],
QueryLimit::new(500)?,
).await?;
let _ = rows;
Ok(())
}
}
DuckDB is bundled for a predictable optional build. Its connection is guarded and every native operation runs through Tokio’s blocking worker pool. Dynamic values are prepared parameters; SQL text remains trusted application structure. Results must declare a 1–10,000 row materialization limit. Scalar, decimal, temporal, interval, geometry and binary values are preserved; unsupported complex result types fail with a typed error instead of being guessed.
Specialized Qdrant and Redis stores
Qdrant has a separate dense-vector contract rather than a document or Active
Record facade. See RAG Systems & Vector Search
for its bounded collection, upsert, delete and cosine-query API. The project
wizard and deterministic CLI expose it as the additive --qdrant choice.
Redis native structures are also explicit and namespaced:
#![allow(unused)]
fn main() {
use rullst_orm::{
RedisDataConfig, RedisDataKey, RedisDataStore, RedisField, RedisMember,
RedisScanLimit, RedisStructuresRepository, RedisValue,
};
async fn redis_example() -> Result<(), Box<dyn std::error::Error>> {
let config = RedisDataConfig::new(
std::env::var("REDIS_URL").unwrap_or_default(),
"my-application",
std::env::var("REDIS_USERNAME").unwrap_or_default(),
std::env::var("REDIS_PASSWORD").unwrap_or_default(),
);
let data = RedisDataStore::connect_or_mock(config).await?;
let account = RedisDataKey::new("account:42")?;
data.hash_set(
&account,
&RedisField::new("display_name")?,
&RedisValue::new("Ada")?,
).await?;
data.set_add(&account, &RedisMember::new("reader")?).await?;
let roles = data
.set_scan(&account, RedisScanLimit::new(100)?)
.await?;
let _ = roles;
Ok(())
}
}
The adapter also supplies atomic signed hash increments, exact membership,
Sorted Set add/top ranking and exact per-structure deletion. Empty or mock_*
endpoint/ACL credentials choose the deterministic fallback. Live remote Redis
requires rediss://; intentionally unauthenticated development uses the
loopback-only constructor. Hash values are capped at 1 MiB, members at 4 KiB,
and scans/ranges at 1–1,000 accepted rows. Redis may still allocate a protocol
value before client-side validation, so untrusted writers require server ACLs,
quotas and isolation. Lists, Streams, cluster/failover, eviction and durable
Pub/Sub are not part of this datastore contract.
What this feature does not promise
- no cross-backend transaction, replication, migration, or consistency layer;
- no claim that document databases implement SQL joins or Active Record;
- no automatic choice of a datastore based on a model;
- no managed backups, production credentials, provider homologation, indexes, cluster failover, or performance guarantee;
- no universal “support for every database”.
Run the application’s exact feature set in addition to the full workspace gates. Remote production readiness still requires a disposable live environment, negative authorization tests, backup/restore rehearsal, and operational review.
38. Transactional Outbox & Durable Effects
Use a transactional outbox when a committed database change must eventually produce an external effect even if the application process stops between the two operations. Typical examples are a webhook, an e-mail request, a search projection or a message published to another service.
after_commit is the simpler choice for a best-effort, process-local effect.
Outbox is the durable choice. It stores an event in the same relational
transaction as the domain mutation, then lets an independent worker claim and
deliver it later.
1. Register the schema as a migration
Applications using the built-in SQLx migration runner can register the versioned migration supplied by the ORM:
#![allow(unused)]
fn main() {
use rullst_orm::OutboxMigration;
use rullst_orm::schema::migration::Migration;
fn migrations() -> Vec<Box<dyn Migration>> {
vec![Box::new(OutboxMigration)]
}
}
In a generated project, add Box::new(rullst::db::OutboxMigration) to the
vector returned by get_migrations() in src/migrations/mod.rs, then run
cargo rullst db:migrate.
Outbox::install() applies the same idempotent DDL directly and is useful in
tests or explicit local setup. It never runs automatically. A production team
should review and track the migration through the same deployment process as
its domain schema; do not create tables opportunistically while serving a
request.
The built-in contract supports the SQLx relational backends: SQLite, PostgreSQL, MySQL and MariaDB. It does not span MongoDB, DuckDB, Turso or SurrealDB transactions.
2. Commit domain state and event together
Choose a stable stream boundary and a deterministic event key. A stream can
represent an application or tenant, but it is only a namespace: the framework
does not infer authorization from it.
#![allow(unused)]
fn main() {
use rullst_orm::{Error, Orm, Outbox};
use serde_json::json;
async fn create_invoice() -> Result<(), Error> {
Orm::transaction(|_| Box::pin(async move {
// `?` placeholders are SQLite/MySQL syntax; PostgreSQL uses `$1, $2`.
let insert = sqlx::query(
"INSERT INTO invoices (id, status) VALUES (?, ?)"
)
.bind(123_i64)
.bind("issued");
rullst_orm::execute_query!(insert, execute, pool)?;
Outbox::enqueue(
"tenant-42",
"invoice:123:issued:v1",
"invoice.issued",
&json!({"invoice_id": 123}),
).await?;
Ok::<(), Error>(())
})).await?;
Ok(())
}
}
If the transaction rolls back, neither row survives. This also holds when a
helper wraps enqueue in its own Orm::transaction and is called from inside
another one: the nested call joins the outer transaction through a savepoint,
so its outbox row commits or rolls back with the outer transaction. Calling
enqueue outside Orm::transaction fails instead of silently opening an
unrelated transaction.
When the application already owns a raw SQLx transaction, use
enqueue_with_tx(&mut transaction, ...).
(stream, event_key) is unique. Repeating the same kind and an equal JSON
payload returns the existing event ID with inserted == false, also when a
concurrent transaction committed that key after your transaction’s first read
(MySQL/MariaDB read the row back with a locking read). Payloads are compared as
JSON values, so an object serialized with its keys in another order (for
example one built from a HashMap) is the same content; array order and every
value still count. Reusing that key for different content is an error; it does
not overwrite the original event, and the error does not echo the stream or key.
Streams and event keys are case-sensitive on every backend: order:aB3x and
order:Ab3X are different keys, and a worker for tenant-a does not claim
events of tenant-A. MySQL/MariaDB declare the key columns with the binary
ascii_bin collation for this. A MySQL/MariaDB table created by an earlier
version used the server’s case-insensitive default collation; run
Outbox::install() once, for example from a new migration, to convert it in
place. The conversion rebuilds the table, so schedule it like other DDL.
3. Claim, deliver and acknowledge
Each worker supplies a stable identifier, lease duration and maximum number of claims:
#![allow(unused)]
fn main() {
use rullst_orm::{Error, Outbox};
async fn deliver() -> Result<(), Error> {
if let Some(event) = Outbox::claim_next(
"tenant-42",
"webhook-worker-1",
30, // lease seconds
8, // maximum claims
).await? {
let payload = event.payload()?;
// Perform the external effect. Pass event.event_key to a provider that
// supports idempotency, or deduplicate it in the receiving service.
let delivered = payload.get("invoice_id").is_some();
if delivered {
let owned = Outbox::acknowledge(event.id, event.claim_key).await?;
if !owned {
// The lease expired or another worker reclaimed the event.
// Do not assume ownership or mutate the newer claim.
}
} else {
Outbox::fail(
event.id,
event.claim_key,
"provider temporarily unavailable",
8, // maximum attempts
15, // retry delay seconds
).await?;
}
}
Ok(())
}
}
An ACK or failure transition succeeds only for the exact, unexpired random
claim token. A failed delivery becomes pending after the bounded delay, or
dead_letter when its attempt limit is reached. If a worker dies during its
final claim, the next claim sweep moves that expired event to dead-letter
instead of retrying forever.
Claims, acknowledgements and failures commit on their own. Calling them inside
Orm::transaction returns a validation error, because an acknowledgement there
would already be durable if the handler’s transaction later rolled back. Commit
the handler’s database work first, then acknowledge.
4. Understand the guarantee
The delivery guarantee is at least once:
- the worker claims an event;
- the external provider accepts the effect;
- the process stops before the database ACK;
- the lease expires and the event is delivered again.
No local database design can atomically commit an arbitrary remote HTTP side effect. Make the consumer idempotent using the stable stream/event key. Do not use a random key on every retry.
Other explicit limits:
- key fields are 1–128 characters from a bounded ASCII grammar;
- JSON payloads are at most one MiB;
- leases are 1–3,600 seconds, attempts are 1–100 and retry delays are 0–86,400 seconds;
- a failure message is 1–512 bytes without control characters;
- ordering is not guaranteed across retries or concurrent workers;
- generated observers are not automatically converted into outbox events;
- cleanup, retention, tenant authorization and the worker supervision loop belong to the application;
- monitor retry and dead-letter state through your normal database operations.
This separation keeps model saves predictable while giving applications a durable primitive where the event schema and operational policy are explicit.
5. Relay into rullst-messaging
With the umbrella messaging-orm-outbox feature, OrmOutboxRelay<B> maps one
exact outbox stream to one topic on any static MessageBroker. It validates the
claimed JSON, uses the stream-scoped event_key (the lowercase hex SHA-256 of
the stream, /, then the event key) as the broker idempotency key, publishes
and then acknowledges the exact ORM claim. Its executable crash-window test stops after
the first publish, reclaims the expired event and observes an exact broker
replay with only one retained message.
That bridge improves composition; it does not change the guarantee. ORM commit, broker publish and ORM ACK are not one distributed atomic transaction. Operate the claim loop, retry/dead-letter policy, cleanup, authorization and final consumer deduplication explicitly. See Bounded Brokered Messaging for the relay code.
39. Scout Search Providers
Important
Dependency examples pin the v13 prerelease
=13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and the v12 line reached end of life on 16 October 2026 (see the release record). Commit Cargo.lock for reproducible builds. Use path dependencies only when intentionally testing checkout-local changes. The result ordering, SQL fallback, indexed-field,restore()/force_delete()projection and hit-cap rules below describe the v13 prerelease source (13.0.0-alpha.4).
Scout connects a #[orm(searchable)] SQLx model to one search backend. The
model database remains authoritative; the search index is a projection updated
only after a successful managed commit.
Enable the provider adapters
With the umbrella crate, add orm-scout next to the application’s primary
database feature (SQLite here):
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["orm-scout", "strict-sqlite"] }
Or enable scout-http directly on rullst-orm. The feature supplies bounded
HTTP adapters for Meilisearch, Elasticsearch and Algolia. The in-memory
MockSearchEngine is available without the HTTP feature.
The examples use rullst_orm::… paths: #[derive(Orm)], the model’s
FromRow included, expands to rullst_orm::… paths, so a crate that derives
models depends on rullst-orm directly. Projects generated by
cargo rullst new already list it.
Configure one engine
Configuration is process-wide and fail-closed if code tries to replace an already installed engine:
#![allow(unused)]
fn main() {
use rullst_orm::{MeilisearchEngine, set_search_engine};
fn configure() -> Result<(), rullst_orm::Error> {
let engine = MeilisearchEngine::new(
std::env::var("MEILI_URL").unwrap_or_default(),
std::env::var("MEILI_API_KEY").unwrap_or_default(),
)?;
set_search_engine(engine)?;
Ok(())
}
}
Empty or mock_* credentials deliberately select the deterministic offline
store. That makes local/test behavior explicit and prevents an accidental live
call with missing secrets. A keyless Meilisearch or Elasticsearch development
server must be selected with MeilisearchEngine::local(...) or
ElasticsearchEngine::local(...); those constructors accept only loopback
HTTP. Every endpoint must be an origin URL (no credentials, path, query or
fragment) using HTTPS, or HTTP on a loopback host, and redirects are disabled.
Algolia normally derives its official API origin from the application ID:
#![allow(unused)]
fn main() {
use rullst_orm::AlgoliaEngine;
fn engine() -> Result<AlgoliaEngine, rullst_orm::Error> {
AlgoliaEngine::new("APPLICATION_ID", "ADMIN_API_KEY")
}
}
AlgoliaEngine::with_endpoint exists for an explicitly configured compatible
proxy and applies the same HTTPS/loopback URL policy. Never expose an indexing
or admin key to browser code.
Mark and query the model
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Clone, Debug, Orm)]
#[orm(table = "articles", searchable)]
struct Article {
id: i32,
title: String,
body: String,
}
async fn find() -> Result<Vec<Article>, rullst_orm::Error> {
let results = Article::search("transactional outbox").await.get().await?;
Ok(results)
}
}
With an engine, the results follow the provider’s ranking (best hit first,
also for first() and paginate()) unless you add order_by(...).
Without a configured engine, Article::search(...) falls back to a SQL
LIKE over the model’s persisted columns, excluding #[orm(hidden)],
#[orm(encrypted)], #[orm(masked)] and SecretString fields. % and _
in the query match literally rather than as wildcards.
Generated save/update/delete operations, restore() (re-index) and
force_delete() (removal) project only after the relational commit. The
indexed document omits #[orm(hidden)], #[orm(encrypted)] and
#[orm(masked)] fields, so the provider never receives their values; documents
indexed by earlier versions should be reindexed. Rollback produces no search
write. Provider or search errors remain typed errors; they are not silently
converted into an empty result.
The shared adapter boundary enforces:
- index names (the model’s table) of at most 128 bytes that start with a
lowercase ASCII letter and contain only lowercase letters, digits,
-or_; - positive
i32document IDs; - object-shaped JSON documents no larger than one MiB;
- queries no larger than 1,024 bytes and without control characters;
- at most 1,000 parsed hits and a four-MiB response body;
- five-second connect and twenty-second request deadlines;
- disabled redirects and no secret-bearing error bodies.
The hit cap (rullst_orm::scout::MAX_SEARCH_HITS) applies to the whole shared
index, before generated tenant, model-wide and soft-delete scopes. For a model
with such a scope, an answer of 1,000 IDs is treated as truncated and
search() answers from the SQL fallback instead.
Meilisearch and Algolia asynchronous indexing tasks are awaited with a bounded
poll loop. Elasticsearch uses refresh=wait_for for the adapter operations.
Choose the durability level
The generated Scout hook is process-local after commit. A provider outage is
reported as PostCommit: the model row is already durable and must not be
blindly inserted again. This path is suitable when the application can rebuild
the index or explicitly retry.
If every projection must survive a process crash, write a versioned search
event through Outbox::enqueue in the domain transaction and run the selected
engine from an idempotent outbox worker. Rullst does not invent a stable event
key or serialize every model hook automatically. See
Transactional Outbox & Durable Effects.
Evidence boundaries
The repository runs a real update/search/delete lifecycle against a digest-pinned Meilisearch container. Elasticsearch and Algolia run deterministic offline tests plus local HTTP protocol fixtures that verify paths, headers, payloads, response bounds and ID parsing. Those fixtures do not prove a hosted Algolia account, every Elasticsearch version, cluster failover, ranking quality or production capacity.
41. Tenant-Bound RAG in One Typed Operation
Important
Dependency examples pin the v13 prerelease
=13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and the v12 line reached end of life on 16 October 2026 (see the release record). Commit Cargo.lock for reproducible builds. Use path dependencies only when intentionally testing checkout-local changes.
Rullst’s bounded RAG pipeline turns one authenticated question into a guarded embedding, authorized retrieval request, budgeted context, grounded model call, source metadata, and a terminal audit event.
It deliberately does not hide the datastore or invent authorization. The host application still decides who may ask, which records that identity may read, how documents are ingested and deleted, and which embedding model and vector dimensions define an index.
Add the feature
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["ai"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Run the complete offline contract
An empty or mock_* OpenAI key selects Rullst’s deterministic offline provider.
Its fixture embeddings have 16 dimensions, so the local index below uses the
same exact dimension.
use rullst::ai::rag::{
InMemoryRagAuditTrail, InMemoryRagRetriever, RagDocument, RagPipeline,
};
use rullst::ai::{providers::openai::OpenAiProvider, AiClient};
use rullst::security::TenantContext;
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build this only from verified authentication and membership claims.
let tenant = TenantContext::try_new("tenant:acme")?;
let client = AiClient::new(OpenAiProvider::new("mock_rag"));
let retriever = InMemoryRagRetriever::try_new(1_000, 16)?;
let content = "Rullst upgrades start with cargo rullst upgrade --dry-run.";
let vector = client.embed(content).await?;
let document = RagDocument::try_new(
&tenant,
"upgrade-guide",
content,
0.0, // The retriever replaces this with cosine similarity.
)?;
retriever.upsert(&tenant, document, vector)?;
let audit = Arc::new(InMemoryRagAuditTrail::new(1_000)?);
let pipeline = RagPipeline::new(client, retriever, Arc::clone(&audit));
let response = pipeline
.answer(&tenant, "How do I preview a framework upgrade?")
.await?;
println!("{}", response.answer());
for source in response.sources() {
println!(
"source={} score={} chars={} truncated={}",
source.document_id(),
source.score(),
source.included_chars(),
source.truncated()
);
}
let events = audit.entries()?;
println!("recorded {} terminal RAG event(s)", events.len());
Ok(())
}
Saved as src/main.rs, cargo run prints the deterministic offline answer,
one source=upgrade-guide … line and recorded 1 terminal RAG event(s).
The offline provider is a deterministic test fixture, not evidence of live model quality. In a real application, select a live provider explicitly and evaluate its configured model against a versioned domain corpus.
What the pipeline enforces
For each call, RagPipeline:
- validates the bounded question and runs guarded embedding;
- passes the trusted
TenantContext, query vector, and bounded limit to the application retriever; - rejects over-returned or differently tagged documents;
- runs prompt-injection checks and outbound PII masking on every passage;
- truncates by per-document and total Unicode-scalar budgets;
- refuses to generate when no safe context remains;
- returns only source identifiers, scores, included character counts, and truncation state alongside the answer;
- records exactly one terminal, secret-minimized audit event, or fails the operation if the required audit sink is unavailable.
Tune budgets with RagConfig::try_new(max_documents, max_document_chars, max_context_chars). The constructor accepts 1–32
documents, 1–32,768 characters per document and 1–131,072 context characters
(characters are Unicode scalar values); RagPipeline::with_config accepts only
an already validated configuration.
Use an authoritative production retriever
InMemoryRagRetriever is bounded and tenant-partitioned, but it is process
local, nondurable, and uses an exact linear cosine scan. It is appropriate for
tests, development, and small ephemeral datasets.
Production applications implement the public async RagRetriever trait over
their chosen store. A PostgreSQL implementation should put the tenant predicate
inside the same parameterized pgvector query that performs similarity ordering.
A Qdrant implementation should apply the deployment’s reviewed tenant filter
or separate-collection policy. It then creates each RagDocument from the same
trusted context passed to retrieve.
The essential shape is:
async fn retrieve(
&self,
tenant: &TenantContext,
query_embedding: &[f32],
limit: usize,
) -> Result<Vec<RagDocument>, RagRetrievalError> {
let rows = self.store
.similar_chunks_for_tenant(&tenant.tenant_id, query_embedding, limit)
.await
.map_err(redacted_retrieval_error)?;
rows.into_iter()
.map(|row| RagDocument::try_new(tenant, row.id, row.content, row.score))
.collect()
}
The pipeline verifies the returned tenant tag as defense in depth. That check cannot detect a datastore adapter that incorrectly read another tenant’s row and then falsely relabeled it. Authoritative tenant and ownership filtering must therefore happen in the datastore query itself.
Supply durable, minimized audit evidence
InMemoryRagAuditTrail is useful for local assertions. A single-process
service can use the bounded built-in local file:
#![allow(unused)]
fn main() {
use rullst::ai::rag::DurableRagAuditTrail;
use std::sync::Arc;
let audit = Arc::new(DurableRagAuditTrail::try_open(
"storage/audit/rag.log",
)?);
Ok::<(), Box<dyn std::error::Error>>(())
}
The trail synchronously appends a distinct versioned stream, validates every
event/frame on restart and fails closed on corruption, unsafe file types,
external length changes and quota exhaustion. The default ceilings are 16 MiB
and 4,096 events; try_open_with_max_bytes may select a smaller byte quota.
Its SHA-256 frame digest detects corruption but does not authenticate the
writer. The host owns trusted directory permissions, exclusive writer access,
rotation, retention, backup and export. Multi-instance deployments should
implement RagAuditSink over an append-only, access-controlled destination.
The built-in event contains the tenant ID, a SHA-256 digest of the original question, document counts, included context characters, and the terminal outcome. It omits raw questions, passages, vectors, model answers, and provider error bodies. A digest is not encryption and low-entropy questions may be guessable, so do not expose the audit stream as public data.
Security and quality boundaries
- Prompt-injection filtering is heuristic defense in depth, not proof that a passage or answer is safe.
- Source metadata proves which identifiers entered the prompt; it does not prove that the model cited them faithfully or that their contents were true.
- Apply normal output encoding, domain validation, and tool authorization after generation. Never treat model text as an authorized command.
- Keep ingestion, updates, deletions, retention, embedding-model migrations, ANN tuning, backups, and disaster recovery explicit.
- Test cross-tenant denial, empty retrieval, hostile passages, provider failure, datastore failure, audit failure, and context truncation in the host application.
For the lower-level vector-store contracts, see RAG Systems & Vector Search. For the framework’s broader claims and limits, see the capability ledger.
42. Server-Bound OAuth/OIDC Sessions
Rullst Connect manages the browser-to-provider callback challenge: it generates state and PKCE for OAuth 2.0, adds a nonce for OpenID Connect, keeps those private values on the server and consumes them exactly once before validating the callback. It offers two places to keep them:
- a login challenge store (v13, from 13.0.0-alpha.3) in the application’s
own database, with only an opaque id in a short-lived cookie. A Rullst
application signs users in with its own encrypted session cookie, so this is
the path for it, and
cargo rullst make:oauth google|github|microsoft|applegenerates it; - a
tower-sessionssession (axum-sessionfeature), for an Axum application that already runs aSessionManagerLayer.
Either way this removes security-sensitive plumbing from ordinary handlers. It does not choose the application’s account-linking or authorization policy.
Store-backed login
Generate it
In a project with the account model of cargo rullst auth, the SaaS starter
or the LMS example (examples/lms):
cargo rullst make:oauth google # or github, microsoft, apple
cargo rullst db:migrate
The command adds /auth/<provider> and /auth/<provider>/callback (behind
the password login’s per-client rate limit), a “Sign in with …” link on the
login page, an oauth_identities table (provider and subject, unique) and the
oauth, oauth-<database> and application oauth-mock features. The
generated callback signs the account in with make_login_cookie_with_epoch,
like the password login. A provider email links or creates an account only
when the provider verified it; an email that already belongs to an account is
refused by default, because registration does not prove that the person who
registered it owns the address. See
cargo rullst make:oauth
for the settings and the linking policy.
Run the command once per provider: the first one creates the shared flow
(src/controllers/oauth/mod.rs), the table and its migration; each further
provider adds only its module, routes, login link and settings, so Google and
GitHub (or all four) share one oauth_identities table, one migration and the
same linking policy. A provider identity signs in to one account; linking a
second provider to an existing account follows EXISTING_ACCOUNT_POLICY.
What each provider’s identity is:
| Provider | Protocol | Subject in oauth_identities | Email that may link or create an account |
|---|---|---|---|
| OpenID Connect | ID token sub | ID token email with email_verified: true | |
| GitHub | OAuth 2.0 (not OIDC) | numeric account id of GET /user, never the login name, which users can rename | the primary address of GET /user/emails, only when GitHub marks it verified |
| Microsoft | OpenID Connect, tenant chosen by MICROSOFT_TENANT | <tid>:<oid>, the directory and object IDs (sub is pairwise: it changes with the app registration) | the email claim only with the optional claim xms_edov: true |
| Apple | OpenID Connect, callback by form POST | ID token sub, stable for the developer team | ID token email with email_verified; it may be a private relay address |
Microsoft. MICROSOFT_TENANT is required with the credentials and has no
default, because it decides who can create accounts: common (any Entra ID
organization and personal Microsoft accounts), organizations (any
organization), consumers (personal accounts) or one directory (tenant) ID.
Rullst Connect verifies the ID token against the tenant’s keys and requires an
issuer https://login.microsoftonline.com/<tid>/v2.0 whose tid the tenant
choice admits. Microsoft does not verify the email claim: an organization’s
administrators can give a user any address, including one of another
company. Treating it as verified would let any tenant administrator sign in to
an account here (the “nOAuth” pattern). The generated code therefore trusts it
only with xms_edov: true, Microsoft’s statement that the address’s domain is
verified for the user’s organization (or that the account is a personal one).
Add the optional ID-token claims email and xms_edov under Token
configuration in the app registration; without them a Microsoft identity
cannot create or link an account, and only identities linked before can sign
in. Changing EXISTING_ACCOUNT_POLICY does not change this check.
Apple. Apple returns the callback as a cross-site form POST
(response_mode=form_post). Browsers send no SameSite=Lax cookie with it, so
the generated start uses ChallengeCookie::for_cross_site_post
(SameSite=None; Secure), and the command lists /auth/apple/callback in
csrf_signed_webhook_paths of Rullst.toml: Apple cannot send this site’s
CSRF token, so instead the handler accepts the POST only with the state of
the login challenge that this browser’s challenge cookie names, which another
site cannot know. The client secret is a five-minute ES256 JWT that Rullst
Connect signs for each code exchange from APPLE_TEAM_ID, APPLE_KEY_ID and
the .p8 key in APPLE_PRIVATE_KEY (process environment or a secret store;
newlines may be written as \n; the command never writes it to a file).
AppleProvider::verify_signing_key stops the application at startup when the
key cannot sign. Apple sends the user’s name only on the first authorization,
in the unsigned user form field; the generated code uses it as the new
account’s display name and for nothing else. An Apple email may be a private
relay address (…@privaterelay.appleid.com): register the domain of
MAIL_FROM with Apple’s Private Email Relay service, or account emails never
arrive. Apple accepts only HTTPS return URLs on a registered domain, never
localhost, so develop with the mock credentials.
To try a provider without registering an application, run with mock
credentials and the offline provider, which sends the browser straight back
to the callback (a same-site GET, Apple included) and signs in
[email protected]:
GOOGLE_CLIENT_ID=mock_google GOOGLE_CLIENT_SECRET=mock_google \
cargo run --features oauth-mock
The command prints the mock settings of the provider it added, such as
MICROSOFT_TENANT=common MICROSOFT_CLIENT_ID=mock_microsoft MICROSOFT_CLIENT_SECRET=mock_microsoft. Without --features oauth-mock the
application stops at startup with an error that names the feature, and
staging and production refuse mock credentials.
Or write the two handlers
The store implements ChallengeStore: MemoryChallengeStore for tests and one
process, or SqlChallengeStore over the application’s own SQLx pool with the
sqlite, postgres or mysql feature of rullst-connect (oauth-sqlite,
oauth-postgres or oauth-mysql on the rullst facade, where the module is
rullst::connect::challenge). SqlChallengeStore::open creates its table,
rullst_connect_login_challenges, when it is missing.
#![allow(unused)]
fn main() {
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
use rullst_connect::challenge::{
ChallengeCookie, LoginOptions, MemoryChallengeStore, begin_login, finish_login,
};
use rullst_connect::extractors::AuthCallback;
use rullst_connect::providers::GoogleProvider;
/// A redirect that also sets `cookies`.
fn redirect(target: &str, cookies: &[String]) -> Response {
let mut response = Redirect::to(target).into_response();
for cookie in cookies {
if let Ok(value) = HeaderValue::from_str(cookie) {
response.headers_mut().append(header::SET_COOKIE, value);
}
}
response
}
/// `GET /auth/google`
async fn start(
store: &MemoryChallengeStore,
google: &GoogleProvider,
cookie: &ChallengeCookie,
) -> Response {
match begin_login(store, google, LoginOptions::oidc("google")).await {
Ok(login) => redirect(login.url(), &[login.set_cookie(cookie)]),
Err(_) => StatusCode::SERVICE_UNAVAILABLE.into_response(),
}
}
/// `GET /auth/google/callback`
async fn callback(
store: &MemoryChallengeStore,
google: &GoogleProvider,
cookie: &ChallengeCookie,
headers: &HeaderMap,
params: &AuthCallback,
) -> Response {
let id = ChallengeCookie::read(
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok()),
);
match finish_login(store, google, "google", id.as_deref(), params).await {
Ok(login) if login.user().email_verified == Some(true) => {
// Map `login.user().id` (the provider subject) to an account and
// add the application's session cookie here.
redirect("/dashboard", &[cookie.clear()])
}
Ok(_) | Err(_) => StatusCode::BAD_REQUEST.into_response(),
}
}
}
ChallengeCookie::new("/auth/google", secure) scopes the cookie to the login
routes; pass secure the way Rullst Core decides it,
Environment::requires_secure_defaults(). The cookie is named
rullst_oauth_challenge, so Core also adds Secure to it on every HTTPS
request.
Store contract
begin_loginstores the provider name, state, PKCE verifier, nonce (OIDC only) and an optional same-site return page (ReturnPath: an absolute path, never//hostor/\host), and returns a freshChallengeId: 256 random bits, 43 base64url characters. The challenge lives ten minutes, or less withLoginOptions::with_lifetime.- The browser holds only the id, in an
HttpOnly,SameSite=Laxcookie of at most ten minutes.SameSite=Laxstill sends it with the provider’s top-levelGETredirect. For a provider that POSTs the callback from its own site (Sign in with Apple),ChallengeCookie::for_cross_site_postmakes itSameSite=None; Secure, the only cookie a browser attaches to that POST. Several cookies of that name with different values are refused rather than guessed. - Stores key rows by a SHA-256 digest of the id, never the id. Consumption is
one atomic removal:
DELETE … RETURNINGon SQLite and PostgreSQL,SELECT … FOR UPDATEthenDELETEin one transaction on MySQL, a mutex in memory. Of several concurrent callbacks with one id, exactly one finds it. finish_loginreports a missing cookie, then an unknown, altered, used or expired id, before it reads the callback; from there the challenge is consumed whatever follows. It compares the state in constant time before it trusts a providererror, then exchanges the code with the stored verifier and nonce. Errors are typed (LoginError,ChallengeError) and carry no state, code, verifier, nonce or id.- Each store has an entry limit: a save at the limit first deletes expired
challenges, then refuses with
ChallengeError::CapacityExceeded(purge_expireddeletes them on demand). The limit is exact on SQLite and in memory; on PostgreSQL and MySQL concurrent saves can pass it by at most their number.
Concurrent login tabs each have their own challenge, unlike the one active
challenge of a tower-sessions session.
Alternative: tower-sessions
The rest of this tutorial covers the axum-session feature.
Enable the session feature
Pin the v13 prerelease exactly (Cargo never selects a prerelease on its own; the v12 line reached end of life, see the release record):
[dependencies]
rullst-connect = { version = "=13.0.0-alpha.4", features = ["axum-session"] }
tower-sessions = "0.15"
Applications should use one immutable compatible version. Add a
SessionManagerLayer to the Axum router. MemoryStore is useful
for local examples and tests, but it is process-local and is not a production
durability or horizontal-scaling strategy.
use tower_sessions::{cookie::SameSite, MemoryStore, SessionManagerLayer};
let sessions = SessionManagerLayer::new(MemoryStore::default())
.with_http_only(true)
.with_same_site(SameSite::Lax)
.with_secure(true);
let app = app.layer(sessions);
SameSite::Lax permits the ordinary top-level OAuth callback, a cross-site
GET redirect, while reducing cross-site cookie exposure. It does not cover a
provider that returns the callback as a cross-site POST; see
Sign in with Apple. Production still
requires HTTPS, a durable shared store where multiple instances are used,
bounded store retention, protected keys and an explicit reverse-proxy policy.
Start OAuth 2.0 with state and PKCE
Use this path for providers such as GitHub where the application is using an OAuth authorization-code flow without an ID token:
#![allow(unused)]
fn main() {
use axum::response::Redirect;
use rullst_connect::prelude::*;
use tower_sessions::Session;
async fn start_github(
session: Session,
github: &GithubProvider,
) -> Result<Redirect, ConnectError> {
let authorization = begin_oauth_session(&session, github).await?;
Ok(Redirect::temporary(authorization.url()))
}
}
The returned URL contains the random state and the SHA-256 PKCE challenge. The
64-character verifier is serialized only in the server-side session record.
OAuthAuthorization deliberately redacts its URL from Debug output.
Start OpenID Connect with nonce
Use the OIDC variant for Google or a discovered custom OIDC provider:
let authorization = begin_oidc_session(&session, &oidc_provider).await?;
Ok(Redirect::temporary(authorization.url()))
This stores another random value and sends it as nonce. The provider adapter
receives that same expected nonce later and validates it against the signed ID
token in the adapters whose documented contract includes ID-token validation.
The query-string AuthSession callback does not support Apple; see the next
section.
Sign in with Apple: form POST callback
AppleProvider always requests response_mode=form_post, which Apple requires
when the name or email scope is requested. Apple then returns code,
state, id_token and, on the first sign-in only, user in an
application/x-www-form-urlencoded POST from appleid.apple.com. Two parts of
the query-string flow do not fit that request:
AuthSessionreads the callback from the query string only, so the POST has no state to compare and the extraction fails;- the POST is cross-site, and browsers send a cookie on a cross-site POST only
when it is
SameSite=None; Secure. TheSameSite::Laxsession cookie shown above is not sent, so the callback cannot find its stored challenge.
Do not relax the application’s authenticated session cookie to
SameSite=None to work around this. Keep the Apple challenge in a dedicated,
short-lived store reached through its own SameSite=None; Secure; HttpOnly
cookie scoped to the Apple routes. Without the managed extractor of the next
section, validate the posted form with the framework-neutral primitives:
use rullst_connect::extractors::AuthCallback;
use rullst_connect::pkce::{generate_oauth_state, generate_pkce};
use rullst_connect::prelude::*;
use rullst_connect::provider::ExchangeParams;
// Start: generate the same values the managed flow would store.
let state = generate_oauth_state();
let nonce = generate_oauth_state();
let (code_verifier, code_challenge) = generate_pkce();
let mut url = url::Url::parse(
&apple.redirect_url_with_pkce_and_state(&code_challenge, &state),
)?;
url.query_pairs_mut().append_pair("nonce", &nonce);
// Application-provided: store the three values for ten minutes under a random
// ID and set `apple_challenge=<ID>; Path=/auth/apple; Max-Age=600; Secure;
// HttpOnly; SameSite=None` on the redirect response.
store_apple_challenge(&state, &nonce, &code_verifier).await?;
// Callback: `POST /auth/apple/callback` with a small body limit.
async fn apple_callback(
axum::Form(callback): axum::Form<AuthCallback>,
) -> Result<ConnectUser, ConnectError> {
// Application-provided: atomically remove the challenge named by the
// `apple_challenge` cookie, failing if it is missing or expired.
let challenge = take_apple_challenge().await?;
callback.verify_state(&challenge.state)?;
if let Some(error) = &callback.error {
return Err(ConnectError::Provider(format!("Apple returned {error}")));
}
let code = callback
.code
.as_deref()
.ok_or_else(|| ConnectError::Token("missing code".to_string()))?;
apple
.get_user(ExchangeParams {
auth_code: code,
code_verifier: Some(&challenge.code_verifier),
expected_nonce: Some(&challenge.nonce),
})
.await
}
AuthCallback ignores the extra id_token and user fields. get_user
redeems the code and verifies the returned ID token’s signature, issuer,
audience, expiry and nonce. The unsigned user JSON is the only place Apple
sends the user’s name; parse it separately if needed and treat it as
unverified. After a successful callback, rotate or create the application’s own
authenticated session as usual.
Managed form POST callback
AuthSessionForm (added in 13.0.0-alpha.1) is the form POST counterpart of
AuthSession. It reads an application/x-www-form-urlencoded
POST body of at most 16 KiB, ignores id_token and user, and consumes the
same challenge that begin_oidc_session stored, with the same expiry,
constant-time state comparison and single use. A non-POST request, another
content type or an oversized body is rejected before the challenge is touched.
#![allow(unused)]
fn main() {
use rullst_connect::prelude::*;
async fn apple_callback(
callback: AuthSessionForm,
apple: &AppleProvider,
) -> Result<ConnectUser, ConnectError> {
apple.get_user(callback.exchange_params()?).await
}
}
The cookie requirement does not change: the session that holds the challenge
must be SameSite=None; Secure. Give the Apple start and callback routes their
own session layer rather than relaxing the application session:
use tower_sessions::{cookie::SameSite, MemoryStore, SessionManagerLayer};
let apple_challenges = SessionManagerLayer::new(MemoryStore::default())
.with_name("apple_oauth_challenge")
.with_path("/auth/apple")
.with_http_only(true)
.with_secure(true)
.with_same_site(SameSite::None);
let apple_routes = Router::new()
.route("/auth/apple/start", get(start_apple)) // begin_oidc_session
.route("/auth/apple/callback", post(apple_callback)) // AuthSessionForm
.layer(apple_challenges);
An inner session layer replaces the outer Session for those routes, so the
callback cannot also write the application session. Hand the verified identity
over with an application-owned one-time step, for example a random single-use
handoff ID that expires within a minute and is redeemed by a same-site route
under the application session layer.
Consume the callback
Mount AuthSession directly as an Axum extractor. Extraction parses the real
query, removes and immediately saves the stored challenge, rejects expiry or a
constant-time state mismatch, and makes a later sequential replay fail:
#![allow(unused)]
fn main() {
use rullst_connect::prelude::*;
use tower_sessions::Session;
async fn github_callback(
session: Session,
callback: AuthSession,
github: &GithubProvider,
) -> Result<UniversalProfile, ConnectError> {
let user = github.get_user(callback.exchange_params()?).await?;
// Rotate the browser session before establishing authenticated identity.
session
.cycle_id()
.await
.map_err(|error| ConnectError::Session(error.to_string()))?;
session
.insert("authenticated_user_id", &user.id)
.await
.map_err(|error| ConnectError::Session(error.to_string()))?;
Ok(user.universal_profile())
}
}
Do not serialize ConnectUser as a credential store. Its public serialization
already omits provider tokens, while UniversalProfile is the narrower
credential-free identity projection. If an application needs provider refresh
tokens, place them in a dedicated encrypted store with explicit rotation and
revocation policy.
For a provider that returned both a refresh token and expires_in, construct a
bounded process-local coordinator at the trusted time the token response was
received:
#![allow(unused)]
fn main() {
use rullst_connect::{AutoRefreshingSession, ConnectError, ConnectUser};
use rullst_connect::prelude::ExposeSecret as _;
async fn call_authorized_endpoint(_: &str) -> Result<(), ConnectError> {
Ok(())
}
async fn provider_request(
github: &rullst_connect::providers::GithubProvider,
user: &ConnectUser,
token_received_at: u64,
) -> Result<(), ConnectError> {
let tokens = AutoRefreshingSession::from_user_at(
github,
user,
token_received_at,
)?;
let lease = tokens.access_token().await?;
call_authorized_endpoint(lease.access_token().expose_secret()).await?;
Ok(())
}
}
AutoRefreshingSession<P> checks a bounded early-expiration window and holds
one async process-local refresh gate, so provider refresh calls cannot overlap
and waiters reuse the first valid result. It keeps the old refresh credential if
the provider does not rotate, adopts a valid rotation, requires the same provider
user ID and changes state only after full validation.
Persist refresh state without storing plaintext tokens
Rullst supplies a storage-neutral authenticated envelope. The application still chooses its database/file/secret manager, but the stored token record need not invent its own cryptographic format:
#![allow(unused)]
fn main() {
use rullst_connect::{
AutoRefreshingSession, EncryptedTokenSnapshot, Provider,
TokenSnapshotBinding, TokenSnapshotError, TokenSnapshotKey,
};
async fn seal_current_state<P: Provider + ?Sized>(
session: &AutoRefreshingSession<'_, P>,
key_bytes: [u8; 32],
local_account_id: &str,
) -> Result<EncryptedTokenSnapshot, TokenSnapshotError> {
let state = session.state_snapshot().await;
let binding = TokenSnapshotBinding::try_new("github", local_account_id)?;
let key = TokenSnapshotKey::try_new("oauth-primary-2026", key_bytes)?;
EncryptedTokenSnapshot::seal(&state, &key, &binding)
}
}
Write only EncryptedTokenSnapshot::as_str() to durable storage. On restart:
- parse the stored string with
EncryptedTokenSnapshot::try_from_envelope; - read its non-secret
key_id()and select that 32-byte key in a secret manager; - rebuild the same binding from trusted provider and local-account state;
- call
open, then pass the restored state toAutoRefreshingSession::new.
The AES-256-GCM authentication tag covers the envelope version, key ID,
provider and local account, so a copied record fails for another owner. The
payload is bounded and revalidated after decryption. Debug output is redacted,
and the envelope itself does not implement Display or Serde. The application
must still commit each new generation transactionally, rotate/retain keys,
authorize the account and revoke local state. Multi-process deployments also
need a distributed compare-and-set/lease; retry/backoff, reauthentication and
replay of the original API request are deliberately not inferred.
Lifecycle and failure semantics
The managed contract is intentionally small:
- a challenge expires ten minutes after it is created;
- there is one active challenge per browser session;
- starting a second flow replaces the first, so the older browser tab fails;
- authorization URLs must use HTTPS or exact loopback HTTP, contain no URL credentials/fragment, and preserve exactly one generated state and S256 PKCE tuple without a preconfigured nonce;
- the challenge is removed and saved before state, nonce or PKCE-dependent exchange;
- missing, mismatched, expired and later sequential callbacks fail closed;
- the form POST variant accepts only a
POSTwith a form body of at most 16 KiB and leaves the challenge untouched when it rejects the request shape; - provider error text is bounded before it becomes a typed error;
- callback codes, state, nonce, verifier and authorization URLs are redacted
from the managed types’
Debugoutput.
One active challenge makes replay and lifecycle behavior unambiguous, but it is not the best UX for applications that intentionally support concurrent login tabs. Such an application should use the store-backed login, which keys each attempt by its own opaque id with the same expiry, atomic consumption, constant-time comparison and redaction properties.
The generic tower-sessions store interface does not expose a distributed
compare-and-delete. Two requests that already loaded the same record can still
race even though each removal is saved immediately. Provider authorization
codes are themselves single-use, but account creation/linking and authenticated
session establishment must still be idempotent. Deployments requiring a strict
distributed callback claim should use the store-backed login, whose SQL stores
consume a challenge atomically.
What the application still must prove
Before release, test the exact deployed provider and browser path:
- The registered redirect URI exactly matches the application route and uses HTTPS outside an exact loopback development host.
- The session cookie remains Secure and HttpOnly, uses an intentional SameSite policy, and is rotated after successful authentication.
- Every application instance sees the same durable session store, or routing is deliberately constrained without pretending failover works.
- Issuer, audience, signature, expiry and nonce checks pass and fail against the provider’s real or restricted environment.
- Account creation/linking cannot attach an attacker-controlled provider identity to an existing local account.
- Native or mobile clients do not sign in by sending a provider access token
to
get_user_from_token: a userinfo response does not prove the token was issued to this application’sclient_id. Verify their ID token withverify_id_token(Google,OidcProvider) and a server-issued nonce. - Denial, timeout, provider outage and abandoned-login recovery have bounded user-visible behavior without logging credentials.
The local Rullst regressions prove generation, round-trip, mismatch, missing state, expiry, replacement, replay, typed exchange parameters and redaction, and, for the store-backed login, concurrent single consumption on SQLite (and on owned PostgreSQL and MySQL servers in CI). They are not provider certification or deployment evidence.
43. Installable Apps: PWA, Google Play and Microsoft Store
A Rullst application is already a web application. cargo rullst make:pwa
makes it installable: browsers on Android, ChromeOS, Windows, macOS and Linux
offer to install it, iOS users add it to the home screen from Safari, and the
same site can be published in Google Play and the Microsoft Store. There is one
codebase, and the server stays the authority for every rule and every piece
of data.
make:pwa replaces the Omni shells (make:omni), which 13.0 removes; see the
migration guide.
Make the application installable
Run the generator at the root of an application created from an HTML starter (Blank or SaaS, or an application generated from the former LMS, Blog, Portfolio or ERP starters):
cargo rullst make:pwa --name "Field Notes" --short-name Notes
cargo rullst dev
Every flag is optional; the CLI reference lists them with their defaults. The command adds these files:
| File | Purpose |
|---|---|
static/manifest.webmanifest | Name, colours, start_url, scope, display: standalone, icons. |
static/icons/*.png | Placeholder icons: 192 and 512 pixels, a maskable 512 and the 180-pixel apple-touch icon. |
static/pwa.js | Registers the service worker and shows the update prompt. |
src/pwa.rs | Serves /sw.js and /offline; head() returns the tags a layout needs. |
src/pwa/sw.js | The service worker. |
PWA.md | The checklist for the generated project. |
It also edits the application in three recognized places: the router mounts
crate::pwa::router() next to the health probes, every page layout gains the
PWA tags, and the crate root declares mod pwa. The whole plan is checked
before the first write. Running the command again changes nothing, and a
generated file you have edited stops the run instead of being overwritten.
The JSON API starter has no pages to install and is refused.
Open http://localhost:3000: browsers allow service workers on localhost
and on HTTPS origins only. Chrome’s DevTools Application panel shows the
manifest, the worker and the cache.
Before release, replace the four icons with your own PNG files of the same sizes.
Keep the subject of maskable-512.png inside the central circle of 80% of
its width: launchers crop that icon to their own shape.
A layout that make:pwa does not recognize
The generator recognizes an html! document, a document written in a string
and Core’s render_page_with_lang. For any other layout, add the tags
yourself, inside <head>:
pub fn layout(title: &str, body: Markup) -> Markup {
rullst::html! {
<!DOCTYPE html>
<html lang="en">
<head>
<title>{title}</title>
{crate::pwa::head()}
</head>
<body>{body}</body>
</html>
}
}
With Core’s layout, render_page_with_head places the same tags at the end
of the page’s <head>:
render_page_with_head(&htmx, "en", "Dashboard", crate::pwa::head(), content)
And mount the routes once, outside any CSRF layer (they answer GET only):
router.merge_axum(crate::pwa::router().into_axum())
How it fits the security defaults
Content-Security-Policy. The default policy is
default-src 'self'; script-src 'self' 'nonce-…'; … worker-src 'self' blob:.
static/pwa.js is a same-origin file, so script-src 'self' loads it without
a nonce, and it styles the update prompt through the DOM rather than a style
attribute, which style-src refuses. /sw.js is allowed by worker-src 'self', and the manifest, which has no manifest-src directive, falls back to
default-src 'self'. A policy with 'strict-dynamic' ignores 'self'; give
the registration script the request’s nonce in that case.
Cache-Control. Rullst answers Cache-Control: no-store when a response
sets no policy of its own, and the worker never stores such a response, nor a
private one, a non-GET request or a response from another origin. Personal
and authenticated pages therefore never stay on the device. Static files are
served with no-cache (content-hashed names with a year-long immutable
policy), /offline and /sw.js with no-cache. A page that is the same for
every visitor, such as a landing page, can opt into storage with
rullst::cache_control; the worker then keeps a copy for offline
visits.
What the worker does
- Install: stores
/offline, the manifest,pwa.js, the 192 and 512 icons and the starter’srullst.css, htmx and logo when they exist. The install fails if/offlinecannot be stored. - Navigations: network first. A storable page is kept; without network the worker answers with the stored copy or the offline page.
- Static files: a name that carries a content hash
(
app.3f9a2c1b.css, the rule Rullst’s static server uses for its year-long cache) is served from the cache first; other files go to the network first and fall back to the stored copy. - Everything else (forms, htmx fragments, JSON APIs, other origins) goes to the network untouched.
- Updates: the cache is named after
[package].version, whichsrc/pwa.rswrites into the worker it serves. Release a new version and browsers install the new worker next to the old one; the page shows “A new version of this app is available” with a Reload button, which sendsSKIP_WAITINGto the waiting worker and reloads once it takes control. The old cache is then deleted. Until the served worker changes (a new version, or an edit tosrc/pwa/sw.js), browsers keep the installed one and do not fetch the offline page again; network-first pages and files still refresh their stored copy whenever they load online.
Data while offline
The worker never queues writes. When a page must accept changes without a connection, keep the rule the server already applies to every client: the server is the authority, and a queued change is only a proposal. Store each pending write in IndexedDB with an idempotency key and send it again when the connection returns:
// static/outbox.js: load it like pwa.js, as a same-origin script.
const STORE = 'writes';
function database() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('app-outbox', 1);
request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'key' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function done(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = resolve;
transaction.onerror = () => reject(transaction.error);
});
}
// Queue a write; the key travels with every attempt to send it.
export async function queue(url, body) {
const db = await database();
const write = { key: crypto.randomUUID(), url, body, queuedAt: Date.now() };
const transaction = db.transaction(STORE, 'readwrite');
transaction.objectStore(STORE).put(write);
await done(transaction);
return write.key;
}
// Send the queued writes in order; stop at the first network failure.
export async function flush(csrfToken) {
const db = await database();
const read = db.transaction(STORE).objectStore(STORE).getAll();
const writes = await new Promise((resolve, reject) => {
read.onsuccess = () => resolve(read.result);
read.onerror = () => reject(read.error);
});
writes.sort((a, b) => a.queuedAt - b.queuedAt);
for (const write of writes) {
let response;
try {
response = await fetch(write.url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': write.key,
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(write.body),
});
} catch (error) {
return; // Still offline: try again on the next `online` event.
}
// Accepted, already applied or refused by the server: it is settled.
if (response.ok || response.status === 409 || response.status === 422) {
const transaction = db.transaction(STORE, 'readwrite');
transaction.objectStore(STORE).delete(write.key);
await done(transaction);
} else {
return; // Sign-in expired or the server failed: keep it and stop.
}
}
}
Call flush with the page’s CSRF token (the _token value its forms carry)
from an online listener and when the page loads. The outbox holds what the
user typed on this device until it is sent: queue only what the page needs and
clear the store when the user signs out.
On the server, accept each key once. Validate it, store it with the result in
the same transaction as the write (a unique index on the key), answer a
repeated key with the stored result, and check permissions, ownership and
revisions again on every replay. IdempotencyKey validates the header before
any work:
#![allow(unused)]
fn main() {
use rullst::client_contract::IdempotencyKey;
use rullst::http::HeaderMap;
/// The replay key of a queued write, or `None` when it is missing or malformed.
fn replay_key(headers: &HeaderMap) -> Option<IdempotencyKey> {
let value = headers.get("idempotency-key")?.to_str().ok()?;
IdempotencyKey::new(value).ok()
}
let mut headers = HeaderMap::new();
headers.insert(
"idempotency-key",
"3f6c2a9e-8b1d-4c57-9a0e-2d4f6b8c1e7a".parse().unwrap(),
);
assert!(replay_key(&headers).is_some());
headers.insert("idempotency-key", "short".parse().unwrap());
assert!(replay_key(&headers).is_none());
}
Never let a queued write carry a role, a tenant, a price, a score or a timestamp the server trusts: the server derives them from the signed-in session and its own clock.
Google Play through a Trusted Web Activity
A Trusted Web Activity (TWA) is an Android app that opens your site in the user’s browser engine, full screen, without browser UI. Google’s Bubblewrap builds one from the web manifest; PWABuilder uses it for its Android packages. The app holds no copy of your pages: deploying the site updates it.
1. Generate the Bubblewrap configuration
The site must already be served over HTTPS at its final host. Then:
cargo rullst make:pwa --name "Field Notes" --short-name Notes \
--android --host notes.acme.dev --package-id dev.acme.notes
--host is the site’s host name (no scheme or path) and --package-id the
Android application ID, which you own and can never change once published
(com.example.* is refused). make:pwa --android can run after a plain
make:pwa when it repeats that run’s flags, as here; it then adds only
android/twa-manifest.json and PWA-ANDROID.md. Other values would change
the files already written, so the run stops before writing anything. The
configuration repeats the web manifest: name, short
name as launcherName, colours, display, start URL, the 512 icon as
iconUrl and the maskable one as maskableIconUrl, plus
webManifestUrl, fullScopeUrl, appVersion 1/appVersionCode 1 and
enableNotifications: false. The signingKey entry
(/private/path/upload.keystore, alias upload) is a placeholder.
2. Install Bubblewrap and create the upload key
Bubblewrap needs Node.js 18 or later and JDK 17; on its first run it offers to download the JDK and the Android command-line tools:
npm i -g @bubblewrap/cli
Android apps are signed, and every update must be signed with the same key.
Create the upload key once with the JDK’s keytool, outside the repository,
and let it prompt for the passwords (never pass them as arguments, where they
reach shell history and logs):
keytool -genkeypair -keystore /private/path/upload.keystore \
-alias upload -keyalg RSA -keysize 3072 -validity 10000
Restrict the file to your account, back it up with its passwords, and write
its real path and alias into signingKey in android/twa-manifest.json.
Keep *.keystore and *.jks out of version control and CI artifacts.
3. Build the bundle
cd android
bubblewrap update --skipVersionUpgrade
bubblewrap build
bubblewrap update generates the Android project from twa-manifest.json
(with --skipVersionUpgrade it keeps version 1), and bubblewrap build asks
for the key passwords (or reads BUBBLEWRAP_KEYSTORE_PASSWORD and
BUBBLEWRAP_KEY_PASSWORD) and writes app-release-bundle.aab and
app-release-signed.apk.
Bubblewrap’s own quick start is interactive instead:
bubblewrap init --manifest https://notes.acme.dev/static/manifest.webmanifest
reads the same manifest, asks you to confirm each value (compare them with
android/twa-manifest.json), creates a signing key and the project, and is
followed by bubblewrap build. Run it in an empty directory.
4. Play App Signing and Digital Asset Links
Upload the .aab in Play Console. With Play App Signing, which Play requires
for new apps, Google signs what users install with its app signing key;
your key only uploads. Android shows the app full screen only when the site
publishes the fingerprint of the key that signed the installed app, so copy
the SHA-256 certificate fingerprint of the app signing key from Play
Console’s App integrity page. Add the upload key’s fingerprint too if you
install builds signed locally (keytool -list -v -keystore /private/path/upload.keystore prints it).
The generated src/pwa.rs serves /.well-known/assetlinks.json once the
application is told which app to name. Set the variable in the environment or
.env:
RULLST_ANDROID_ASSET_LINKS="dev.acme.notes=<app signing SHA-256>,<upload SHA-256>"
or add the app to Rullst.toml (the variable wins when both are set):
[[android.asset_links]]
package_name = "dev.acme.notes"
sha256_cert_fingerprints = [
"14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5",
]
Separate several apps with ; in the variable, or repeat the table. Each
fingerprint must be 32 colon-separated hexadecimal bytes; an invalid value is
reported when the application starts and the file is then not served. The
route answers application/json with Cache-Control: public, max-age=3600.
Code outside the generated module can build the same route:
#![allow(unused)]
fn main() {
use rullst::pwa::{AndroidApp, AssetLinks};
let fingerprint = "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5";
let links = AssetLinks::new().with_app(AndroidApp::new("dev.acme.notes", [fingerprint])?);
assert!(links.to_json().contains("\"package_name\":\"dev.acme.notes\""));
let router = rullst::Router::new().merge_axum(links.router());
let _ = router;
Ok::<(), rullst::pwa::AssetLinksError>(())
}
Restart the application and check
https://notes.acme.dev/.well-known/assetlinks.json; Google’s
Statement List Generator and Tester
verifies it against the package. If the address bar still shows in the
installed app, the fingerprint does not match the key that signed it.
5. Updates
Web changes reach installed apps immediately; a new bundle is needed only for
Android-side changes (name, icon, colours, start URL, package version). Then
run bubblewrap update (it raises appVersionCode by one and saves
twa-manifest.json) and bubblewrap build, and upload the new bundle signed
with the same upload key.
Microsoft Store
As documented by Microsoft in Publish a PWA to the Microsoft Store, the same site is published without code changes: PWABuilder packages it as MSIX, and the package carries the manifest’s metadata and icons but none of the application’s code, which keeps loading from your server.
- Before packaging: the site is deployed over HTTPS with the manifest
make:pwawrote (itsid, name, colours and icons; replace the placeholder icons first, because the package copies them). - Reserve the name: with a personal Microsoft account enrolled in the Windows Developer Program, open Partner Center, choose Apps and games → New product → MSIX or PWA app and reserve the name. Under Product management → Product Identity, copy the Package ID, Publisher ID and Publisher display name.
- Package: on PWABuilder, enter the
site’s URL. Its report card evaluates the site and manifest and lists
action items; when it is store ready, choose Package For Stores →
Windows → Generate Package, paste the three values and download the
.zip, which holds an.msixbundleand a.classic.appxbundle. - Submit: in Partner Center, start a submission for the reserved app, answer the pricing and age-rating questions and upload both bundles. Microsoft reviews it before it appears in the Store.
Updates. Microsoft’s page states that changes to HTML, CSS, JavaScript or
the service worker reach users from your server without a new package.
Changing the manifest (name, icons, or members such as share_target)
requires a new package and submission, because the manifest is copied into
the Windows package. Generate it with PWABuilder again, with a higher app
version in its Windows options, and choose Update on the app in Partner
Center (PWABuilder’s update steps).
Apps installed from the Store send Referer: app-info://platform/microsoft-store
on their first navigation, which lets you count them.
iPhone and iPad
There is no App Store package. Apple’s
App Store Review Guideline 4.2
asks for “features, content, and UI that elevate it beyond a repackaged
website”, so an app that only shows the site is usually rejected, and a
native iOS app is a separate product. iOS users install the PWA from Safari
instead: Share → Add to Home Screen. The home-screen app uses the
apple-touch-icon and name that make:pwa provides and opens in its own
window, and Safari runs the same service worker and offline page.
45. Accessible Academy Media
Rullst’s LMS example (examples/lms) contains a bounded lesson-presentation
foundation for video and audio. Authorization and progress remain server-owned, while the
browser receives accessible media markup and an escaped transcript.
Start from the LMS example
Before 13.0.0-alpha.4, cargo rullst new --blueprint lms generated this
application; it now lives in the Rullst repository as
examples/lms. From
a checkout of the repository:
cd examples/lms
cargo test -p rullst-lms-example
Copy the directory to start your own academy (its README shows how to run it and how to switch to the published crates). It includes the course catalog, modules, lessons, this accessible player, enrollment, progress, login and a Nexus admin. Earlier releases also generated a complete Academy (assessment, gamification, automation and notifications); v13 removed it in favor of this smaller application.
Lesson media contract
Lessons store these fields:
| Field | Contract |
|---|---|
media_kind | Closed renderer values: video or audio. |
media_url | HTTPS URL or absolute same-origin path; control characters and backslashes are rejected. |
captions_url | Required valid source for video; normally a same-origin .vtt path. |
transcript | Required and bounded; the HTML renderer escapes it. |
language_tag | Required bounded ASCII language tag such as en or pt-BR. |
For production, prefer application-owned same-origin or signed media. Put a
caption file at static/media/lesson.pt-BR.vtt and store the public source as
/static/media/lesson.pt-BR.vtt; Server mounts the local static/ directory
at /static.
WEBVTT
00:00.000 --> 00:04.000
Bem-vindo à primeira atividade.
The example intentionally ships no media binary. Its four seeded
development lessons point at remote sample video/audio files, and its two
video lessons use the static/media/memory-safety.en.vtt and
static/media/first-project.en.vtt captions. The default CSP
(default-src 'self') does not allow that remote media, so replace the seeded
lessons in Nexus (/nexus) with your own content. Add your reviewed
audio/video asset or application-specific object-storage delivery, then use a
same-origin path such as /static/media/lesson.webm. If you choose a remote
host, add only that reviewed origin to a media-src directive of the csp
template under [security] in Rullst.toml; do not weaken the policy to
arbitrary HTTPS. Core also defaults COEP to
require-corp, so the remote media server must emit a compatible
Cross-Origin-Resource-Policy response. When a reviewed server cannot do so,
an application can explicitly set coep = "credentialless" under [security]
in Rullst.toml; browsers then omit credentials for eligible cross-origin
no-CORS requests. Prefer same-origin delivery, and test the exact CSP/COEP/media
combination in the deployed browser rather than disabling isolation globally.
What the player enforces
- no autoplay;
- native video/audio controls;
- a caption track for every video;
- an always-available transcript for video and audio;
- visible keyboard focus and nonce-bound styles;
- escaped title and transcript values;
- fail-closed rendering for unknown kinds, insecure sources or invalid accessibility metadata.
The protected lesson controller checks the authenticated learner’s active enrollment before rendering the player. Progress submissions use CSRF and idempotency data and remain authoritative in the database. Each rendered player carries a fresh random key, scoped to the requested percentage on submission: resubmitting the same click replays the stored event, while a later save, or another button, records new progress.
Evidence boundary
The example’s own tests, run in the repository’s workspace suite, cover catalog query bounds, progress keys, the production security baseline and the learning service’s owner boundary; no test renders the player or its rejected source/metadata cases. They do not prove codec support, buffering behavior, screen-reader quality, subtitle accuracy, microphone or speech recognition, physical mobile devices, CDN delivery or app store behavior. Run browser accessibility tests with your real content and deployment before making those claims.
48. Signed Local OIDC Testing
Important
Dependency examples pin the v13 prerelease
=13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and the v12 line reached end of life on 16 October 2026 (see the release record). Commit Cargo.lock for reproducible builds. Use path dependencies only when intentionally testing checkout-local changes.
Rullst Connect includes an explicitly mounted local identity-provider fixture so
an application can exercise a cryptographically verified OIDC flow without a
third-party account. Unlike an in-process provider stub, this path traverses
HTTP discovery, authorization, token exchange, JWKS retrieval and ID-token
verification through the ordinary OidcProvider implementation.
Enable and bind the fixture
Enable the Axum feature in development:
[dev-dependencies]
rullst-connect = { version = "=13.0.0-alpha.4", features = ["axum"] }
A project that enables the rullst facade’s oauth feature (as
cargo rullst make:oauth does) already has this feature: the module is
rullst::connect::mock_idp.
Mount the router only on an exact loopback listener. Axum is reached through
the rullst::web::axum re-export, because generated projects do not depend on
Axum directly:
#![allow(unused)]
fn main() {
use rullst_connect::mock_idp::{MockIdpConfig, mock_router_with_config};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let issuer = "http://127.0.0.1:8080";
let callback = "http://127.0.0.1:3000/auth/callback";
let config = MockIdpConfig::try_new(
issuer,
"academy-local-client",
"academy-local-secret",
callback,
)?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
rullst::web::axum::serve(listener, mock_router_with_config(config)).await?;
Ok(())
}
}
MockIdpConfig rejects non-HTTP or non-loopback issuers and callbacks, issuer
paths, empty/oversized identifiers and control characters. The application
must still bind the actual listener to loopback; possession of a router does not
control how the host serves it.
Configure the ordinary OIDC client
Use non-placeholder local credentials so OidcProvider traverses the HTTP
fixture: empty or mock_* credentials select the separate network-free
credential fallback instead (and are rejected without the mock feature):
#![allow(unused)]
fn main() {
use rullst_connect::providers::OidcProvider;
async fn provider() -> Result<OidcProvider, rullst_connect::ConnectError> {
OidcProvider::discover(
"http://127.0.0.1:8080",
"academy-local-client",
"academy-local-secret",
"http://127.0.0.1:3000/auth/callback",
).await
}
}
Compose this provider with the server-bound session ceremony from
Tutorial 42. That ceremony generates and
stores state, PKCE verifier and OIDC nonce; the fixture binds the challenge and
nonce to its one-shot authorization grant. The callback then supplies the
consumed values to OidcProvider::get_user.
What the test proves
The checked-in loopback regression proves that the current client can:
- validate discovery metadata on the exact issuer origin;
- preserve an exact registered client and callback;
- exchange one expiring authorization code only once;
- reject a missing, malformed or mismatched S256 PKCE verifier;
- bind the requested nonce into a signed ID token;
- select the Ed25519 public key by
kidfrom JWKS; - validate EdDSA signature, issuer, audience, expiry and nonce; and
- accept only an issued, unexpired bearer token at userinfo.
Authorization grants and access-token digests are process-local and capped at 64 records. A restart erases them. This is intentional test behavior, not a durable identity service.
Non-production boundary
The signing seed, client credentials and identity are deterministic public fixtures. The router has no interactive login, consent, refresh-token, device, federation, account-management, key-rotation or administrative lifecycle. It has not passed an OIDC conformance suite. Never expose it publicly, reuse its key or credentials, or use a successful fixture test as evidence that a live provider/deployment is correctly configured.
49. Bounded Brokered Messaging
Important
Dependency examples pin the v13 prerelease
=13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and the v12 line reached end of life on 16 October 2026 (see the release record). Commit Cargo.lock for reproducible builds. Use path dependencies only when intentionally testing checkout-local changes.
This tutorial starts with rullst-messaging’s deterministic in-memory broker
and then switches the same trait contract to the durable local SQLite adapter.
Remote broker protocols remain separate adapters.
1. Enable the umbrella feature
[dependencies]
rullst = { version = "=13.0.0-alpha.4", features = ["messaging"] }
Use messaging-sqlite instead of messaging for durable local state. A direct
dependency uses rullst-messaging = { version = "=13.0.0-alpha.4", features = ["sqlite"] }.
2. Create a bounded broker
#![allow(unused)]
fn main() {
use rullst::messaging::{BrokerConfig, InMemoryBroker};
fn create_broker() -> Result<(), rullst::messaging::MessagingError> {
let config = BrokerConfig::try_new("billing")?
.with_limits(
10_000, // retained messages
128, // consumer-group subscriptions
5, // delivery attempts
1024 * 1024, // payload bytes
)?;
let broker = InMemoryBroker::new(config);
let _ = broker;
Ok(())
}
}
The hard ceilings prevent an accidental configuration from turning the local broker into unbounded memory state. Capacity exhaustion fails closed until terminal messages are explicitly purged.
Persist the same contract locally
#![allow(unused)]
fn main() {
use rullst::messaging::{BrokerConfig, SqliteBroker};
async fn open() -> Result<(), rullst::messaging::MessagingError> {
let config = BrokerConfig::try_new("billing")?
.with_limits(10_000, 128, 5, 1024 * 1024)?;
let broker = SqliteBroker::connect("sqlite://storage/messages.sqlite", config).await?;
let _ = broker;
Ok(())
}
}
All mutations use serialized SQLite write transactions. A committed publish or ACK survives restart, while an uncommitted operation rolls back. In-flight leases also survive; after expiry the next broker operation requeues or dead-letters them. Multiple processes may share one file and namespace, but reopening it with different limits is rejected. This is local durability, not network replication, automatic failover or exactly-once side effects.
Protect header values and payloads
SqliteBroker::connect deliberately retains the compatible plaintext profile.
For a new namespace/database, load a 32-byte high-entropy key from a secret
manager and select the encrypted profile explicitly:
#![allow(unused)]
fn main() {
use rullst::messaging::{
BrokerConfig, MessagingKeyring, MessagingStorageKey, SqliteBroker,
};
async fn open_encrypted(
secret_manager_key: [u8; 32],
) -> Result<(), rullst::messaging::MessagingError> {
let primary = MessagingStorageKey::try_new("messages-2026-09", secret_manager_key)?;
let keyring = MessagingKeyring::new(primary);
let broker = SqliteBroker::connect_encrypted(
"sqlite://storage/messages.sqlite",
BrokerConfig::try_new("billing")?,
keyring,
).await?;
let _ = broker;
Ok(())
}
}
To rotate, put the new primary first and append the old decryption key:
#![allow(unused)]
fn main() {
use rullst::messaging::{MessagingKeyring, MessagingStorageKey};
fn rotate(new_key: [u8; 32], old_key: [u8; 32])
-> Result<MessagingKeyring, rullst::messaging::MessagingError> {
let keys = MessagingKeyring::new(MessagingStorageKey::try_new(
"messages-2026-10",
new_key,
)?)
.with_decryption_key(MessagingStorageKey::try_new(
"messages-2026-09",
old_key,
)?)?;
Ok(keys)
}
}
New publications use the primary. Old records are not silently rewritten and their keys remain mandatory until those messages become terminal and are purged. Plaintext/encrypted profiles cannot be mixed or changed in place; move through a new namespace/database with an application-reviewed republish procedure. Passwords are not AES keys, and source literals are unsuitable for production key custody.
The encrypted profile protects header values and payloads. Topic, event and content type, message/key IDs, timestamps, idempotency key, fingerprint and delivery state remain visible metadata. Protect the database file and backups, control rollback, rehearse key recovery and authorize topic access separately.
3. Register a consumer group and publish
#![allow(unused)]
fn main() {
use rullst::messaging::{
MessageBroker, PublishRequest, StartPosition, SubscriptionRequest, TraceContext,
};
async fn example(
broker: &rullst::messaging::InMemoryBroker,
) -> Result<(), rullst::messaging::MessagingError> {
broker
.subscribe(SubscriptionRequest::try_new(
"invoices",
"receipt-mailers",
StartPosition::Earliest,
)?)
.await?;
let trace = TraceContext::try_with_state(
"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"vendor=value",
)?;
let request = PublishRequest::try_new(
"invoices",
"invoice.paid",
"invoice/2026-0042/paid/v1",
br#"{"invoice_id":"2026-0042"}"#.to_vec(),
)?
.with_content_type("application/json")?
.with_trace_context(&trace)?;
let first = broker.publish(request.clone()).await?;
let replay = broker.publish(request).await?;
assert_eq!(first.id(), replay.id());
assert!(replay.is_duplicate());
Ok(())
}
}
Idempotency is scoped to the topic. The same topic/key with different content
returns MessagingError::IdempotencyConflict; it never silently overwrites the
original publication.
Only validated W3C version-00 traceparent and a conservative tracestate
subset are copied. baggage is deliberately excluded because arbitrary
application metadata may contain credentials or personal data. Sampling,
export, retention and tenant-aware correlation remain host responsibilities.
4. Receive, acknowledge, retry, or dead-letter
#![allow(unused)]
fn main() {
use rullst::messaging::{
FailureCode, MessageBroker, ReceiveRequest, RetryDisposition,
};
use std::time::Duration;
async fn consume(
broker: &rullst::messaging::InMemoryBroker,
) -> Result<(), rullst::messaging::MessagingError> {
let request = ReceiveRequest::try_new(
"invoices",
"receipt-mailers",
"worker-1",
10,
Duration::from_secs(30),
)?;
for delivery in broker.receive(request).await? {
let effect_succeeded = true;
if effect_succeeded {
broker.ack(delivery.ack_token()).await?;
} else {
let disposition = broker
.retry(
delivery.ack_token(),
Duration::from_secs(15),
FailureCode::try_new("mail.transient")?,
)
.await?;
if disposition == RetryDisposition::DeadLettered {
// Alert through an application-owned operational channel.
}
}
}
Ok(())
}
}
If an adapter needs a stable broker-neutral representation, encode the received envelope explicitly:
#![allow(unused)]
fn main() {
use rullst::messaging::{BrokerConfig, MessageEnvelope, WireEnvelopeCodec};
fn frame(
envelope: &MessageEnvelope,
) -> Result<(), rullst::messaging::MessagingError> {
let config = BrokerConfig::try_new("billing")?;
let bytes = WireEnvelopeCodec::encode(envelope, &config)?;
let decoded = WireEnvelopeCodec::decode(&bytes, &config)?;
assert_eq!(&decoded, envelope);
Ok(())
}
}
The v1 frame is canonical, bounded by BrokerConfig, namespace-bound and
rejects unknown versions, truncation, trailing bytes and non-canonical fields.
It carries an accepted envelope only. It does not retain the caller’s publish
idempotency key, connect to a broker, or map remote ACK/retention semantics, so
it cannot be presented as a Kafka/NATS/RabbitMQ adapter by itself.
An ACK token is an opaque, single-use capability bound to one group and one lease. Once it expires, the message is requeued or dead-lettered at the attempt ceiling. Do not serialize tokens into logs or application records.
5. Design for at-least-once delivery
The consumer may complete the mail/payment/webhook effect and stop before the ACK reaches the broker. Therefore:
- use
delivery.envelope().id()as a stable deduplication key; - claim that key transactionally in the side-effect system where possible;
- ACK only after the effect is durably accepted;
- treat retries and dead letters as normal operational states;
- authorize topics and tenant scope in the host application.
The in-memory broker loses state on process exit. The SQLite adapter retains
state; its default profile is plaintext, while the explicit encrypted profile
protects header values and payloads but not routing/idempotency metadata. The
host owns key custody, file permissions, protected backup/restore, rollback
detection, retention, disk monitoring and topic/tenant authorization. A future
remote adapter is supported only after it
passes the shared contract plus its own protocol, restart, and fault matrix; an
adapter name alone is not durability evidence. The optional v13
Redis Streams candidate (messaging-redis) is such a
standalone transport, with explicit operational limits and Rullst-owned
delivery indexes; it passed hosted source admission, and its final release
admission remains separate. Other remote adapters remain future work.
6. Relay a relational outbox after commit
Enable messaging-orm-outbox on the umbrella crate together with the ORM
backend the application uses (drivers-all or one strict-* feature; the
relay feature enables orm but no SQL driver). Commit the domain mutation
and rullst_orm::Outbox::enqueue together as described in the
transactional outbox tutorial. Then bind one
exact outbox stream to one broker topic:
#![allow(unused)]
fn main() {
use rullst::messaging::{
InMemoryBroker, MessagingError, OrmOutboxRelay,
};
fn configure_relay(
broker: InMemoryBroker,
) -> Result<OrmOutboxRelay<InMemoryBroker>, MessagingError> {
OrmOutboxRelay::try_new("tenant-a", "domain-events", broker)
}
}
A supervised worker claims and relays one event:
#![allow(unused)]
fn main() {
use rullst::messaging::{InMemoryBroker, OrmOutboxRelay};
use rullst_orm::Outbox;
async fn relay_one(relay: &OrmOutboxRelay<InMemoryBroker>) {
let claim = match Outbox::claim_next("tenant-a", "worker-a", 30, 8).await {
Ok(Some(claim)) => claim,
Ok(None) | Err(_) => return,
};
match relay.relay_and_ack(claim).await {
Ok(receipt) if !receipt.outbox_acknowledged() => {
// The lease expired; a later claim will replay the same event key.
}
Ok(_) => {}
Err(error) => {
// Record a secret-free operational failure and let the lease expire.
let _accepted_before_ack_failure = error.accepted_publication();
}
}
}
}
The ordering is intentionally publish then ACK. A stop in between causes a new
ORM claim to publish the same event and content under the same broker
idempotency key; the broker returns its original ID as a duplicate. The v13
source derives that key from the stream and the event key (the lowercase hex
SHA-256 of the stream, /, then the event key), so relays of different streams
can share one topic. This is at-least-once relay with bounded idempotency, not
an atomic transaction across two systems. Keep the worker supervised and make
the final consumer idempotent too.
50. Auditable Revisions
Rullst SQLx models can bind each instance mutation to a validated principal and restore eligible update revisions as a new compensating mutation. The audit row and model write share a transaction; an audit failure rejects the write.
This contract is opt-in and does not infer identity from request data. Install the principal and tenant contexts only after your authentication and authorization boundary has validated them.
1. Mark the model and install the table
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "projects", auditable, tenant_column = "account_id")]
struct Project {
id: i32,
account_id: String,
name: String,
#[orm(masked)]
api_token: String,
}
async fn install() -> Result<(), rullst_orm::Error> {
rullst_orm::audit::create_audit_table().await?;
Ok(())
}
}
create_audit_table also adds the v2 columns to the legacy audit table. Legacy
records retain version 1 and cannot be restored.
On MySQL/MariaDB, new audit tables store old_values, new_values and
restore_patch as LONGTEXT; TEXT holds only 64 KiB, so a larger audited
change would fail the mutation in strict SQL mode (or be truncated otherwise).
A table created by an earlier version keeps TEXT columns and
create_audit_table only logs a warning. Apply this reviewed migration during a
maintenance window (it rebuilds the table):
ALTER TABLE rullst_audits
MODIFY old_values LONGTEXT,
MODIFY new_values LONGTEXT,
MODIFY restore_patch LONGTEXT;
Fields whose names contain password, token, secret, API key, credential, cookie
or similar markers must use #[orm(masked)] on auditable models. Their values
are never retained in audit payloads or reverse patches. The same applies to
such keys inside JSON fields: when an update adds or removes one, the reverse
patch keeps only the key’s presence and that revision cannot be restored.
#[orm(masked)] and #[orm(encrypted)] fields appear as "***" in every
generated audit payload, whatever their name. When an update changes one of
them, the audit row still records the field (as "***" on both sides) so the
change is visible without its value, and that revision cannot be restored.
2. Bind a mutation to trusted context
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "projects", auditable, tenant_column = "account_id")]
struct Project { id: i32, account_id: String, name: String, #[orm(masked)] api_token: String }
use rullst_orm::audit::{AuditContext, with_audit_context};
use rullst_orm::with_tenant;
async fn update(mut project: Project) -> Result<(), rullst_orm::Error> {
let audit = AuditContext::user("user-42")?
.with_correlation_id("request-01J...")?;
with_tenant("account-a", with_audit_context(audit, async move {
project.name = "New name".to_string();
project.save().await
}))
.await?;
Ok(())
}
}
Background work can use AuditContext::service("billing-worker"); reviewed
maintenance can use AuditContext::system("migration-2026-08"). Empty,
padded, control-character, and oversized identifiers fail validation. An
auditable mutation without an active context also fails closed.
3. Restore an eligible update
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, rullst_orm::Orm)]
#[orm(table = "projects", auditable, tenant_column = "account_id")]
struct Project { id: i32, account_id: String, name: String, #[orm(masked)] api_token: String }
use rullst_orm::audit::{AuditContext, with_audit_context};
use rullst_orm::with_tenant;
async fn restore(project: Project, audit_id: i32) -> Result<Project, rullst_orm::Error> {
let actor = AuditContext::user("admin-7")?
.with_correlation_id("support-case-1042")?;
let restored = with_tenant("account-a", with_audit_context(actor, async move {
project
.restore_revision(audit_id, "approved support rollback")
.await
}))
.await?;
Ok(restored)
}
}
The returned model contains the restored state. Rullst verifies the exact
model, ID, active tenant, patch version and current post-state before writing.
PostgreSQL and MySQL/MariaDB lock the row during this check. Success creates a
new updated audit entry with reverted_audit_id and the bounded reason; audit
history is not rewritten or deleted.
Use restore_revision_with_tx when a caller-owned SQLx transaction must include
other relational writes. Prefer Orm::transaction when generated post-commit
observers must wait for the final commit decision.
Deliberate refusal cases
Restoration fails for:
- legacy v1, create, or delete entries;
- a revision from another model, record, or tenant;
- a row changed again after the selected revision;
- a patch containing a redacted/sensitive change, including any change to an
#[orm(masked)]or#[orm(encrypted)]field; - malformed, empty, too deep, too large, or excessively wide patches.
A change whose reverse patch would be too deep, too large or too wide (for example two multi-megabyte values or thousands of changed JSON keys) is still saved and audited; the revision is simply recorded without a restore patch and refused on restoration.
Those refusals prevent audit history from becoming an unsafe generic backup mechanism. Use reviewed database backups and restore drills for disaster recovery. Bulk update/delete builders do not invent per-row audit history, and cross-process export or notification should use the transactional outbox.
Supervised Development Auto-Reload
cargo rullst dev and cargo rullst dash rebuild and restart a directly
linked application when you save. This keeps Tokio, ORM pools, sessions and
other process globals in one runtime. Supervised restart replaced the
advertised DLL-swap profile in v12, after Windows/LMS testing exposed an
uninitialized ORM in the loaded library, and 13.0 keeps it (see
the v13 decision; the
v12 release audit
records the original one).
Run the development loop
Generate a normal application and use either development command:
cargo rullst new learning-app --default --blueprint blank \
--database sqlite --skip-initial-migration
cd learning-app
cargo rullst dev
# Or, in an interactive terminal:
cargo rullst dash
There is no hot-reload question in the wizard. Both commands supervise
reloads automatically; use cargo run for ordinary execution. The
dashboard also shows live requests/s, latency, errors and, when reported, ORM
and queue figures, and r restarts the application without rebuilding (see the
CLI reference).
What a save does
flowchart LR
A[Save source or assets] --> B[Coalesce changes]
B --> C[Compile executable]
C -->|Failure| D[Keep current application and show diagnostic]
C -->|Success| E[Snapshot new executable]
E -->|Failure| D
E -->|Success| F[Stop owned child and start replacement]
F --> G[Verify process generation over HTTP]
G --> H[Browser refresh]
The watcher observes source, static assets, templates and manifest/configuration files, including atomic editor renames. It excludes build outputs, Git data and runtime database/log files. Events while a build is running remain pending for a later rebuild. Saving CSS also triggers this conservative rebuild/restart path; no compilation-free claim is made.
Initial migrations run before the first start when the generated migration
directory exists. Later migrations are explicit: use the dashboard migration
action or cargo rullst db:migrate. The dashboard queues migrations through the
supervisor, using its current executable snapshot with bounded output and
cancellation cleanup; the action is serialized with rebuilds. It does not compile
an unsaved or unbuilt migration into that snapshot. Restarting a process does
not apply arbitrary database-schema changes safely.
Failure and state boundaries
| Event | Behavior |
|---|---|
| Compiler error | Keep the current process running; retain bounded diagnostics. |
| Snapshot copy/create error | Keep the current process running and report the failure before stopping anything. |
| Successful build | Snapshot the executable, stop the owned child, start the replacement. |
| Replacement cannot spawn | Attempt to restart the prior executable snapshot and report the error. |
| Replacement exits during startup | Report the exit; save a correction to rebuild and retry. |
| Readiness cannot be verified in 15 seconds | Report the limitation; do not claim successful readiness. |
| Dashboard exit / Ctrl+C | Cancel owned build/migration work and terminate/reap the application, subject to the Windows descendant limitation below. |
| Configured port changes | Restart the CLI so its dashboard/readiness target follows the new port. |
The old binary is copied before execution so Windows does not lock Cargo’s output during the next build. Cleanup targets only the snapshot created by this supervisor. On Unix the child receives a two-second shutdown interval, followed by termination of its owned process group if needed; group cleanup precedes reaping the leader. Windows uses best-effort tree termination: without Job Objects it cannot guarantee cleanup of orphan descendants after their parent has exited, and it does not promise graceful request drain.
All process-local state resets, including in-memory sessions/queues/caches. Persist important state explicitly. In-flight requests may be interrupted. This is a local development loop, not zero-downtime production deployment.
Browser and security boundary
The debug/development server serves a local reload script and an opaque
generation marker under /_rullst/dev-*. The script polls the same origin
and refreshes only after a different valid generation responds. Readiness
checks match the child generation rather than trusting any service on the port.
The marker is not a secret or an authentication credential. The same
development processes also serve /_rullst/dev-telemetry for the
cargo rullst dash metrics, to loopback clients only (see the
telemetry guide). These
endpoints cannot instruct the server to compile, launch a process, or swap a
library.
Eligible full-document, known-size, uncompressed HTML up to 10 MiB receives the script and a nonce seeded before inner header middleware. HTMX partial requests, streaming/larger/compressed responses pass through without injection. A browser singleton prevents duplicate pollers, and changed HTML is marked no-store. Polling has a request timeout and bounded retry delay. Browser refresh loses unsaved DOM-only state. Custom routers that do not use the Rullst Server development composition may need manual browser refresh.
Production/release builds do not mount this browser surface. Keep development
servers on loopback and do not override their host for an untrusted network.
The current supervisor probes 127.0.0.1 and the configured port; custom HOST
or RULLST_HOST bindings, including IPv6-only loopback, are not equivalent
verified-readiness configurations. Keep the default host and restart the CLI
after changing its port configuration.
Existing DLL scaffolds and the v13 decision
The CLI (since v12) rejects the old --hot-reload generation flag and removes
HOT_RELOAD when it launches a child, so old generated projects use their
directly linked router unless their application reloads that variable itself.
Remove legacy HOT_RELOAD entries from .env as well: application-owned dotenv
loading can otherwise restore them. A newly generated project has no DLL
layout; already edited application code may need application-specific migration.
The existing library loader is retained only as a legacy experimental boundary. Passing Rust, Axum, Tokio or SQLx objects across a library ABI does not provide a stable interoperability contract. Do not reintroduce cross-library ORM pools.
13.0 keeps supervised restart. DLL swapping would return only if its safety requirements can be established and it offers a measured practical benefit over supervised restart, compared with real starters and databases on Windows, Linux and macOS: cold/warm reload time, failed-build recovery, cancellation, memory growth, process cleanup and state ownership.
Terminal accessibility
Use RULLST_REDUCED_MOTION=1 cargo rullst dash for static rendering with
colors, and add NO_COLOR=1 to remove the colors as well
(NO_COLOR=1 RULLST_REDUCED_MOTION=1 cargo rullst dash). Use
cargo rullst dev with redirected/non-interactive output.
Tutorial 52: Typed Server Functions
#[server_function] lets one concrete async Rust signature describe both the
native server implementation and its Wasm caller. The transport is explicit:
the macro also creates a <function>_rpc_router() that you mount in the server.
It does not discover routes through runtime reflection.
use rullst::{Router, server_function};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct SumResponse {
pub value: u32,
}
#[server_function(path = "/api/rpc/math/add")]
pub async fn add(left: u32, right: u32) -> rullst::rpc::RpcResult<SumResponse> {
Ok(SumResponse {
value: left.saturating_add(right),
})
}
pub fn rpc_routes() -> Router {
add_rpc_router()
}
fn main() {
let _router = rpc_routes();
}
When this function is compiled for the native server, its written body runs.
When compiled for wasm32, calling add(20, 22).await serializes (20, 22)
and returns the decoded RpcResult<SumResponse>. Transport failures are
machine-readable RpcFailure values; they never become a fabricated default
application value. The caller exists only in a wasm32-unknown-unknown build
of the crate, such as the island build in
Tutorial 18.
Mount the generated route inside server policy
Merge the generated router before applying the standard security baseline and
your domain layers. rullst::Server applies that baseline to the router it
serves, so there it is enough to merge the generated router into the
application router; the example calls apply_security_baseline directly to
show the composition:
use rullst::{Router, server_function};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
pub struct SumResponse { pub value: u32 }
#[server_function(path = "/api/rpc/math/add")]
pub async fn add(left: u32, right: u32) -> rullst::rpc::RpcResult<SumResponse> {
Ok(SumResponse { value: left.saturating_add(right) })
}
fn secured_transport() -> Result<axum::Router, rullst::SecurityBaselineError> {
let app = Router::new()
.merge_axum(add_rpc_router().into_axum())
.into_axum();
rullst::apply_security_baseline(
app,
rullst::SecurityConfig::default(),
rullst::config::Environment::Production,
)
}
fn main() -> Result<(), rullst::SecurityBaselineError> {
let _app = secured_transport()?;
Ok(())
}
The production baseline verifies the double-submit CSRF cookie/header pair.
The Wasm caller reads the bounded rullst_csrf cookie and forwards it as
X-CSRF-Token. Server issues that cookie only in staging and production;
without it (as in Development) the caller sends no token and lets the server
decide, and a non-JSON 403 is reported as rpc.csrf_token_missing. Other
non-JSON failures are rpc.http_failure; retryable() is true for 429,
502, 503 and 504 and for any response with Retry-After, which covers
the framework’s lifecycle-drain and Traffic Shield rejections. The
application must still add session/authentication, trusted tenant resolution,
object/role authorization and rate limiting in the order documented by
ProductionPreset. Never accept role, owner or tenant authority
from a function argument.
Failure codes
Application failures use the same lowercase dotted-code grammar as the shared client contract:
#![allow(unused)]
fn main() {
fn capacity_failure() -> Result<rullst::rpc::RpcFailure, rullst::client_contract::ClientContractError> {
rullst::rpc::RpcFailure::application("course.capacity_reached", false)
}
let failure = capacity_failure()?;
assert_eq!(failure.code(), "course.capacity_reached");
Ok::<(), rullst::client_contract::ClientContractError>(())
}
Do not place provider bodies, database errors, PII, tokens or debug text in a failure code. Log sensitive diagnostics only through an approved server-side telemetry policy.
When the server rejects an envelope that still carries a valid request_id
(an unsupported version such as a cached older Wasm client, or a payload of the
wrong shape), the failure echoes that identifier. The caller therefore receives
rpc.version_unsupported or rpc.request_invalid rather than
rpc.correlation_mismatch. Content-type and body-size rejections happen before
the body is parsed and still use a server-generated identifier; the generated
client applies the same limits before sending.
Exact limits
- zero to 16 simple identifier parameters;
- owned parameter types and one owned output type implementing the needed Serde traits;
- a concrete async free function with no generics, receiver,
unsafe, extern ABI or variadic arguments; rullst::rpc::RpcResult<T>as the return type;- an optional same-origin path below
/api/rpc/, using at most 128 ASCII bytes; - 256 KiB encoded request and response policy;
- JSON POST transport with a versioned envelope and request-ID correlation.
These bounds do not make a mutation exactly once. Put a stable idempotency key in the domain payload and enforce it transactionally on the server when replay would be harmful. Actual browser-engine compatibility, network availability and deployed identity policy must be tested by the application.
Architecture choices in generated projects
cargo rullst new records product capabilities that materially change the
generated application. Like 12.x, Rullst 13.0 generates one audited application
architecture instead of presenting incomplete compatibility markers as equal
implementations. The framework specification and
capability ledger remain authoritative.
ORM style
Database-backed projects are generated with Active Record. The CLI does not
ask for a global ORM architecture and does not accept an --orm flag. The
framework’s repository APIs remain available when an application needs an
explicit persistence boundary.
| Architecture | In 13.0 | Trade-off |
|---|---|---|
| Active Record | The only generated profile | Concise, but persistence concerns remain close to models. |
| Data Mapper / Repository | Application-owned: the repository APIs exist, but no starter generates this profile | More explicit persistence boundary and more code to maintain. |
| Hybrid | Not a global project profile | The two styles may naturally coexist per module, but the application must define that boundary. |
Earlier v12 prerelease menus offered three labels, but Repository and Hybrid shared generation branches and support varied by blueprint. Removing those selectors does not remove ORM APIs; it prevents the generator from claiming equivalent end-to-end profiles before the generated routes, services and tests support them consistently. All database operations still need parameter binding, authorization and transaction design.
For most CRUD-oriented applications, start with Active Record and introduce a repository around domains that genuinely need a separate persistence boundary.
Database engines and capabilities
The first database question (--database) chooses exactly one primary
backend:
| Selection | Boundary |
|---|---|
| SQLite | Local SQLx pool, migrations and relational ORM contract. |
| PostgreSQL | SQLx PostgreSQL pool and live container CRUD/schema contract. |
| MySQL | SQLx MySQL pool and live MySQL CRUD/schema contract. |
| MariaDB | The same MySQL protocol implementation with a separate live MariaDB contract. |
| Turso | Typed Turso/libSQL primary profile for the Blank and API starters, without Nexus, Studio, Schema or the SQLx ORM; the SQLx-specific SaaS starter does not offer it. |
The Blank starter can instead have no primary database (--no-database, or
No database in the wizard).
A second multi-select adds zero or more independent capabilities and accepts
Enter with no selection. Capabilities already selected by the primary profile
or CLI flags are omitted. The same screen also offers AI, Redis and a
Dockerfile (--ai, --redis, --docker); the side stores have their own
flags (--turso, --mongodb, --duckdb, --surrealdb, --qdrant). Turso supplies explicit edge SQL,
transactions and checked migrations; MongoDB supplies portable document CRUD; DuckDB supplies
bounded analytics; SurrealDB supplies document CRUD and bounded read-only graph
queries; Qdrant supplies bounded dense-vector operations. Selecting one adds the
precise Cargo features and environment keys, but does not make every model
portable between different database families.
This split prevents a Turso, MongoDB, analytics, or graph label from generating an application whose SQL Active Record migrations cannot run. See the Polyglot Persistence guide for the APIs and limits.
Frontend profile
Projects are generated with server-rendered html! views and HTMX
enhancement (cargo rullst new reports Zero-Bundle HTMX (html! SSR)); the
CLI does not ask for a frontend engine or accept a --frontend flag. The five
labels shown by earlier v12 prerelease wizards were not five complete,
interchangeable renderers:
| Earlier label | In 13.0 |
|---|---|
| HTMX + Tailwind SSR | The generated scaffold: server-rendered html! views with the starter’s own stylesheet (static/rullst.css; no Tailwind build step). There is no project-local SPA bundle, but HTMX and styles are still browser assets that must be pinned and served. |
| LiveView | Core supplies LiveComponent, Live::mount and live_ws_handler, and make:live scaffolds a component. The application registers routes and supplies the HTMX WebSocket extension, authentication and reconnect policy. |
| Wasm Island | #[island], make:island and build:client provide a dual-target hydration foundation. Asset delivery, CSP, state, routing and browser E2E remain application work. |
| Pico.css | Not generated. The application adds, pins and serves Pico.css and validates the resulting pages. |
| Tera | Not generated. The application adds the dependency and its own file-template renderer. |
The intended direction is to model independent capabilities instead of one
misleading global frontend selector: rendering (SSR or API), interaction (HTMX,
LiveView or Wasm islands), styling (the bundled design or an explicitly
materialized CSS system), and templating (html! or a fully generated
file-template path). A combination should appear only after its assets, routes
and browser behavior have equivalent tests; 13.0 generates none of them.
Full-stack versus headless API
- Full-stack starters (Blank, SaaS) include server-rendered routes and relevant local tool integration.
--api(the API starter) generates a JSON-oriented Blank project without HTML view rendering; the SaaS starter rejects it instead of silently retaining its HTML routes.
Generated code is application code. Review its routes, auth boundaries, database schema and dependencies before deployment.
Optional AI
Selecting AI adds rullst-ai and its provider-agnostic client foundation.
Providers still require explicit credentials or the documented deterministic
mock mode. Ollama can keep prompt traffic on infrastructure you operate, but
“local” alone does not establish an air gap. Measure compile time, binary size,
latency, quality and cost for the chosen provider and model.
Cache and queues
Without Redis, Rullst offers process-local memory cache and local queue options. With Redis selected, the generated project can configure the feature-gated Redis adapters.
There is deliberately no silent production fallback from Redis to memory: a distributed deployment that loses Redis must not pretend that independent process-local state is equivalent. Choose the backend explicitly and test its failure policy. Core realtime broadcast/presence remains in-process; Redis cache and queue support does not turn it into distributed WebSocket pub/sub.
Studio and Nexus
- Studio’s supported launcher is debug-only, binds to loopback and verifies direct peer information. It shows only data supplied by real local probes.
- Nexus uses a local-development policy, explicit release credentials or the
application’s own sign-in (
NexusAuthPolicy::application; see the Nexus guide). A dependency or generated route does not remove the need for application RBAC.
Neither interface should be exposed publicly without an application-owned identity, authorization, TLS and network policy.
Installable applications
Rullst uses a web-first model. The web application is the canonical, universally reachable product; the server remains authoritative for domain rules, authentication, authorization, persistence, realtime and security.
cargo rullst make:pwa makes that application installable as a Progressive
Web App: a web app manifest, placeholder icons, /sw.js and an /offline
page, wired into the starter’s router and layouts. The service worker works
under the default nonce Content-Security-Policy and never stores a non-GET
request or a response marked no-store or private, which Rullst answers
by default, so authenticated pages are not kept on the device. The same site
reaches Google Play as a Trusted Web Activity (a Bubblewrap configuration and
/.well-known/assetlinks.json from rullst::pwa::AssetLinks) and the
Microsoft Store through PWABuilder; iOS users add it from Safari. See the
PWA guide.
Until 13.0 the Omni command packaged the site in Tauri shells for desktop,
Android and iOS. Those shells deliberately exposed no native API to the
site, so they gave the same result as an installed PWA while costing three
release workflows, Tauri upgrades and per-platform signing. 13.0 removes
make:omni, cargo rullst omni and the native-only offline-sync feature,
whose only consumer was the shell.
rullst::client_contract supplies one shared rullst.client v1 JSON boundary
for browser, server and future platform-enhanced code. It negotiates positive
versions, rejects unknown outer fields and oversized bodies, and carries typed
payloads, correlation, optional mutation idempotency, server time and bounded
failure codes. It deliberately has no user/tenant/role claim: authentication,
authorization, domain validation and durable replay handling remain server
work. A page that accepts writes offline queues them in IndexedDB with an
idempotency key, as the PWA guide shows; the server accepts each key once.
Engineering invariants
Repository policy requires typed failures rather than production panic!,
unwrap() or expect(), explicit SQL parameterization/sanitization, and the
workspace test/Clippy/format gates. These are review and CI rules, not a claim
that arbitrary application code or third-party dependencies cannot panic.
Rullst exposes Axum/Tower integration so applications can add middleware and routes directly. That escape hatch also means the final security and observability composition belongs to the application.
Practical starting points
- Single-process CRUD application: use the generated Active Record,
html!SSR and HTMX profile with memory cache. - Domain-heavy service: introduce repositories per module with explicit transaction boundaries; 13.0 generates no Data Mapper / Repository profile.
- Multi-process application: a configured shared cache/queue plus deployment tests; do not assume realtime is distributed.
- Rich browser interaction: opt into LiveView or Wasm foundations only with application-owned integration and browser tests; 13.0 generates neither as a complete profile.
Revisit these choices as evidence changes. Do not infer performance, security or availability from a wizard label.
Rullst AI: Developing with Autonomous Agents
Rullst is designed as an AI-native Rust framework. Here that term describes inspectable code-generation and explicit compile-time contracts; it is not a claim of historical priority or proof that an AI will produce correct code.
Some framework designs use runtime reflection, string-based dependency injection, or conventions that are harder to discover through static source inspection. Rullst instead favors visible types and generated Rust, while still retaining ordinary runtime state and framework abstractions where appropriate.
Rullst favors strong typing and compile-time diagnostics. The compiler catches type and ownership errors in generated code, but it cannot validate requirements, security intent, or business correctness; AI-generated changes still require review and tests.
For runtime LLM integrations, consult the machine-readable and documented provider capability matrix. Streaming, native tools, built-in deadlines/retries, and explicit cancellation are not implied by the existence of a provider adapter.
Local Rust tools use a separate guarded execution boundary with an exact allowlist, principal authorization, closed JSON schema, payload limits, call budget and mandatory audit sink. Destructive and financial calls also require a one-use approval bound to the exact payload.
1. Repository instructions for coding agents
The Rullst repository maintains its own contributor AGENTS.md. The v13 CLI
candidate creates separate application instructions during cargo rullst new
and preserves any existing project instructions during regeneration. The
framework repository policy is not copied wholesale into applications.
cargo rullst generate:ai-context writes a bounded metadata inventory to
.llms.txt and .rullst/context-map.json; --check verifies freshness without
writing. Source bodies and configuration values are excluded. Read the
context guide for the scope, limits and legacy migration.
The generated instructions and inventory do not establish feature readiness or
replace the application’s authentication, privacy or deployment decisions.
Conventions to retain when adapting project instructions:
1. **Static Dispatch over Dynamic**: Prefer static dispatch (`impl Trait` or generics) over `dyn Trait` to ensure explicit concrete types for AI context tracking and optimization.
2. **Explicit APIs**: Avoid hidden state. Every controller and middleware should be explicit in its arguments.
3. **HTML Macros**: Boolean attributes in the `html!` macro are bare (`required`) or conditional (`disabled?={is_locked}`); never `disabled="false"`, which still disables.
4. **No Panics**: Never use `unwrap()` or `expect()` in production routes.
2. Rullst’s AI-Friendly Patterns
Rullst’s API is designed to give coding tools more explicit source landmarks; this can reduce ambiguity but does not measure or guarantee hallucination rates:
- Explicit Routes: The
routes![ ... ]macro is visual and delimited. The AI knows exactly where to add a new route without having to search across scattered files. - Rullst ORM:
#[derive(Orm)]generates typed Active Record methods and a fluent query builder over SQLx; a query the builder does not cover is written in SQL with bound values (Orm::rawor SQLx). See the ORM page. - Explicit background workers: a worker registers an async handler under a job name (
worker.register("email", ...)) and is started explicitly;cargo rullst make:workerwrites both parts.
3. How to Get the Best Results
When instructing an AI to add a feature in Rullst:
- Ask it to read the instruction files that actually exist in your application and consult the matching version of the Rullst documentation.
- Point it at an existing file: “Create a new controller following the pattern in
src/controllers/auth_controller.rs” (written bycargo rullst auth). A concrete example in the project gives the assistant a pattern to follow. - Use the generators! Ask the AI to use
cargo rullst make:controllerin the terminal (if it’s an autonomous agent), ensuring the correct file structure. - Run
cargo rullst generate:ai-contextso the assistant in your editor or terminal reads the project’sAGENTS.mdand its.llms.txtinventory, and point it at the framework’s publishedllms.txt. Review every edit it proposes. (cargo rullst ai, the terminal assistant of the earlier 13.0 prereleases, was removed in 13.0.0-alpha.4.)
Rullst Studio: local development control room
Rullst Studio is a developer-facing Axum dashboard. It can run as a standalone
server bound to 127.0.0.1 (port 5555 by default) or its router can be mounted
explicitly by an application.
Studio’s built-in boundary is deliberately limited to debug builds and verified loopback peers. It is not a shared-environment authentication system; do not expose raw subrouters publicly without application-level authentication, authorization, TLS, and network policy.
Projects generated from the Blank, API and SaaS starters (and the LMS, ERP,
Portfolio and Blog examples in examples/) start the standalone server only in
debug builds and link to http://127.0.0.1:5555.
Release builds do not start it; runtime RULLST_ENV or legacy APP_ENV values
cannot override that compile-time boundary.
To watch a deployed application, mount the read-only
production monitoring endpoint instead (new in
v13): it reports request rates, latency, errors, queues and N+1
findings behind a token or the application’s own sign-in. The standalone
rullst-studio binary
(cargo install rullst-studio --version =13.0.0-alpha.4 --features remote --locked,
then rullst-studio or cargo rullst studio --remote) reads it for several projects from your
machine, with a project switcher and encrypted local tokens; see
Watch your projects from Studio.
That remote mode has none of the local pages below.
Running Studio
In a project generated from a starter, run the application in a debug build
(cargo rullst dev) and open http://127.0.0.1:5555/studio: its main
starts the full Studio next to the server. Studio stops with the application.
cargo rullst studio is different: it runs the application with the studio
argument (cargo run -- studio), and Core’s Artisan handler then serves a
smaller local compatibility UI (database, AI, telemetry, security, capital and
span pages) on 127.0.0.1:5555 until it stops; it is not the rullst-studio
router described below. Both use port 5555, so stop cargo rullst dev first.
cargo rullst studio
In your own main, the rullst-studio library entry point starts the full
Studio:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
rullst_studio::run_studio(5555_u16).await
}
Studio uses the process-wide ORM pool that the application initialized
(Server, Artisan or an explicit Orm::init). If none exists when a database
view is requested, Studio initializes it once from the current directory with
the resolver shared by Server and Artisan: the process DATABASE_URL, then
DATABASE_URL from ./.env (never overriding the process environment), then
[database].url parsed from Rullst.toml. Without a configured database the
views report that database tools are unavailable and nothing is created; the
former sqlite://db.sqlite fallback is gone, and errors never echo
configuration content. Applications that call Orm::init with their own URL
should do so before Studio serves requests.
Studio::new().into_router(LocalStudioAccess::loopback_only()) builds the same
debug-only router for explicit composition. The serving stack must preserve
Axum ConnectInfo<SocketAddr> or requests fail closed. An IPv4-mapped peer
(::ffff:127.0.0.1, reported by a dual-stack :: listener) is checked as the
IPv4 address it carries. Unsafe methods need a
same-origin Origin header. Studio responses carry
Referrer-Policy: same-origin, so browsers send the page’s real origin on
Studio’s own form posts and no referrer to other origins. If a host layer
replaces that policy with no-referrer, browsers send Origin: null; Studio
then accepts the request only with Sec-Fetch-Site: same-origin, which page
scripts cannot set. A bare Origin: null, or one from a same-site document
such as another local port, is rejected. Optional OpenAPI and
queue views are enabled with with_openapi and with_horizon.
with_cache opts a supported cache into metadata-only local inspection, while
with_distributed_traces supplies the bounded store shared with a separately
mounted push-only ingestion router.
Current views
/studio: data browser and dashboard shell./studio/radar: runtime probes and recorded spans. Process CPU sampling is implemented for Linux and Windows and the KPI cards refresh from/api/radarevery two seconds. The first delta-based CPU sample, unsupported platforms, and disconnected probes displayUnavailable; Studio does not synthesize a healthy value./studio/security: counters and events emitted by the in-process security store. Audit-chain integrity displaysUnavailableuntil a verifier is connected./studio/capital: the in-process revenue view; it is not an accounting ledger. Its table lists the 20 most recent webhook events and its badge counts every event the process-local manager retains (at most 100)./studio/traces: local spans plus authenticated attribute-free distributed records and explicit slow/repeated SQL-label heuristics./studio/cache: metadata-only view of an explicitly supplied Memory/Redis cache, using opaque identifiers and one-entry invalidation./studio/migrations,/studio/ai,/studio/env,/studio/features, and/studio/er: development tools for their corresponding subsystems./studio/requests: the request log, streamed over SSE from/studio/requests/stream./studio/jobsand/studio/api: the queue view and the API reference, mounted only whenwith_horizonorwith_openapisupplies their source./studio/apirenders the supplied document’s operations, parameters, request bodies and responses on the server (no script or remote asset); the document itself is served at/studio/api/openapi.json, and/studio/api/redirects to the page.
The radar page polls the /api/radar JSON endpoint; /api/revenue and
/studio/security/stats also return JSON, and /metrics serves the Prometheus
text format. The request logger uses SSE. The current crate does not promise a
separate WebSocket telemetry transport or zero runtime overhead.
Tooling boundaries
- The data browser reads, searches, and paginates allowlisted SQLx identifiers.
A page shows at most 25 rows, ordered by the complete primary key (otherwise
by every selected column); the database cuts each cell’s text to 256
characters before it reaches Studio (key columns keep up to 16 KiB for row
actions, and a longer key makes its row read-only), and search terms are
limited to 256 bytes. Search matches the displayed columns (at most 256),
and the record count uses the same predicate. A SQL NULL shows an italic
NULLmarker; a present value Studio cannot decode as text, a number or a Boolean (for example a BLOB that is not UTF-8) shows an amberunreadablemarker instead, and a stored textNULLis shown as an ordinary value. Inside the verified debug-loopback/same-origin boundary, it may edit one primitive non-key value or delete one complete-primary-key-selected row. Inputs are bounded and parameterized; exact deletion confirmation is required and backend-specific types, including MySQL/MariaDB unsigned or zero-filled integers, remain read-only. A row offers only the actions whose form (for Edit, with a maximum-size value) fits the 64 KiB request limit, and an empty text key is addressable. A table is read-only when a primary-key column falls outside the ASCII identifier boundary or the 256-column cap or uses a floating-point type (its rendered text is rounded), and rows whose key isNULL, is not decodable as text or contains a line break or NUL offer no actions. Each write runs in a transaction that commits only when exactly one row changed; any other count is rolled back and fails (non-transactional engines such as MySQL MyISAM cannot roll back). SQLite, PostgreSQL, MySQL, and MariaDB run separate executable contracts. With the defaultsqlx::Anybuild, Studio renumbers bind markers to$nfor PostgreSQL and reads information-schema identifiers asVARCHAR, so the table view, search, row actions and ER diagram also work there; the PostgreSQL contract runs under that build and understrict-postgres. PostgreSQL browsing covers thepublicschema, and every data statement names that schema so thatsearch_pathcannot select a same-named table elsewhere. This does not supply application tenant/RBAC, audit history, undo, or a shared-production database administrator. - The API reference appears only when the application supplies its
OpenApidocument withStudio::with_openapi; Studio does not reverse-engineer arbitrary Axum routes. It is read-only: it shows at most 500 operations and 512 characters of each text field, and it does not send requests to the application (use the JSON document with an external client for that). - The request SSE records method, URI, status and latency. It deliberately does not capture bodies or headers, which commonly contain credentials and PII.
- The jobs view lists the 50 most recent records exposed by a supplied queue;
its processing/failed/completed counts describe only that window, while the
pending count and the purge actions cover the whole queue. Studio previews at
most 256 payload and 512 error characters. It reads records through
Queue::list_job_previews(new in v13), which cuts each payload and error to 2 KiB: the SQLite and Redis drivers cut them inside the store, while other drivers inherit a default that still loads complete records first. SQLite deletes successful rows by default; an application can explicitly selectQueue::sqlite_with_completed_historyfor bounded, transactionally pruned completion history and can purge that history from Studio. Retained payloads require host-controlled access and retention policy. Other drivers expose only the inspection/history contract they implement. Retry and purge require the verified local marker, so the rawjobs_monitor::routerreturns403for them when mounted outsideStudio::into_router. - The ER view inspects SQLite, PostgreSQL, MySQL, or MariaDB metadata with bound lookup values and normalizes Mermaid identifiers. Each column is listed once, and composite foreign keys pair their columns by key position. An unconfigured or unsupported source remains visibly unavailable.
- The feature-flags page changes the database table used by
DbFeatureDriver. Toggles require the verified local marker; the rawfeature_flags::routerreturns403for them. Viewing the page never changes the schema: a missingrullst_feature_flagstable is reported with the schema to add in an application migration, while connection, permission or column errors are reported as query failures without that guidance. A successful toggle invalidates already-warm drivers in the same process; other processes and direct writers converge by TTL unless the host distributes an invalidation signal. - The environment page redacts values by default, also redacts an allowlisted
value that carries URL user information, a bearer token or a secret-named
assignment (such as
?access_token=), and adds only a safe projection of process-globalRullstConfig; URLs, filesystem paths, secrets, cookies and credentials are omitted. - Cache inspection returns at most 100 UI rows containing an opaque keyed identifier, value byte length and remaining TTL. Values and exact logical keys are never rendered, bulk flush is absent, and individual invalidation requires the verified local mutation marker. Custom cache drivers remain unavailable until they implement Core’s bounded metadata contract.
Telemetry contract
Studio reads runtime state exposed by RadarSnapshot, SpanCollector, the
security store, queues, and configured database connections. A counter means only
that the corresponding instrumentation path emitted it; it is not proof that all
traffic passed through that control. Missing sources must remain visibly
unavailable.
Remote producers do not connect to the viewer. The application separately
mounts TraceIngestor::router, distributes a 32–128-byte
TraceIngestionKey, and uses TraceBatchSigner for the exact body and four
headers. Each ingestor binds one exact producer name to one key; multiple
producers use separate endpoints/keys over the same store. The route accepts
1–128 closed v1 spans under 128 KiB, verifies
HMAC-SHA256 plus a 60-second timestamp and one-time nonce, then commits to a
bounded process-local store idempotently. It contains no Studio read or admin
route. TLS/network policy, key custody and rotation, producer authorization,
clock synchronization, label redaction, durable storage and OTLP integration
remain deployment work.
Production boundary
Studio is an optional crate. Its supported run_studio and Studio::into_router
paths reject credential-free use in release builds, but consumers should still
exclude it from production features unless they are implementing and testing a
separate authenticated administrator boundary. Built-in shared production
access remains roadmap work, not a password environment-variable promise.
v12.1.0 browser composition fix
This section is for applications still on 12.0.x; 12.1.0 and every 13.0 prerelease include the fix.
The Portfolio issue report
identified two omissions in the published 12.0.0 raw data-browser router:
its layout requested /studio/assets/studio.css, but only the full local
Studio builder mounted the assets, and its Cache link had no handler.
Applications nesting data_browser::router() under /studio therefore saw
an unstyled page and a Cache 404. The stock Portfolio generator starts a
separate debug-only Studio; the Azure showcase added its own embedded router.
The 12.1.0 fix serves the embedded CSS/client and an honest, unconnected Cache
page through both the raw and nested browser compositions. It does not enable
public Studio administration, connect an application cache automatically, or
weaken the local access checks. The full builder still requires
LocalStudioAccess and an explicitly supplied Studio::with_cache for real
cache inspection. The browser’s unconnected page displays Unavailable, not
zero entries or a fabricated hit rate, and exposes no cache mutation endpoint.
Migrating an existing showcase
- Update the application’s Rullst dependencies and lockfile together to 12.1.0 or later. Installing a newer CLI does not rewrite previously generated application files or redeploy containers.
- Remove the temporary
.route(...)registrations for/assets/studio.css,/studio/assets/studio.css,/assets/logger.js,/studio/assets/logger.js,/cache, and/studio/cacheadded directly todata_browser::router(). They would conflict with the framework’s new routes. Remove the now-unused copied assets and temporary handlers only after checking their other uses. - Preserve the application’s authentication/administrator-authorization layer, trusted TLS boundary, and network restrictions around the entire router. The routing correction does not validate that custom production policy.
- Rebuild and verify that an authorized
/studiopage loads its same-origin stylesheet with HTTP 200 andtext/css, and that/studio/cachereturns a styled Unavailable page unless a supported cache is explicitly wired. Unauthorized requests must still be denied. Check full visits and any application-managed HTMX partial requests, then redeploy deliberately.
The showcase’s temporary Cache page contains hard-coded counters (including
100.0% hit rate); those are not framework telemetry. Remove that workaround
instead of treating its numbers as production measurements. Existing projects
do not gain this source correction solely from a dependency update.
Rullst Nexus: Explicit Admin CMS
Rullst Nexus is a server-rendered administrative CMS for explicitly registered Rullst models.
NexusModel metadata defines the tables, fields, and widgets available in the
panel. Rullst builds CRUD, search, pagination, and batch routes from that
registration; it does not discover an arbitrary database schema automatically.
Derive and register a model
The Nexus derive generates NexusModel metadata for named-field structs. It
infers booleans, integers (FieldKind::Integer bounded by the Rust type, with
u64/usize capped at i64::MAX, so a fraction or out-of-range value is
rejected), floating-point numbers, dates (chrono NaiveDate; DateTime<Utc> and
NaiveDateTime under any path spelling, optional or not) and ordinary text;
semantic widgets that Rust’s type alone cannot reveal are selected explicitly:
#![allow(unused)]
fn main() {
use rullst::db::{Nexus, Orm};
#[derive(Debug, Clone, Orm, Nexus)]
#[orm(table = "users")]
#[nexus(label = "Users", icon = "👥")]
pub struct User {
pub id: i32,
pub name: String,
#[nexus(kind = "email")]
pub email: String,
#[nexus(kind = "textarea", label = "Biography")]
pub bio: String,
#[nexus(kind = "enum", options = "invited, active, suspended")]
pub status: String,
pub is_active: bool,
}
}
The edit form sends only the fields you change, so an edit never rewrites a
value its widget cannot show: NULL (shown as an empty NULL input), an enum
value that is not a registered option (kept selected but disabled), a date-time
with an offset (shown as text), a text, e-mail or URL value with line breaks
or other control characters (shown read-only; declare kind = "textarea" to
edit multi-line text) or a value that cannot be decoded. Emptying a
number, relation, date, time, date-time, enum or JSON field stores NULL; emptying a
text, textarea, e-mail or URL field stores an empty string. A database
NOT NULL constraint therefore rejects clearing a required typed column.
Dates and times
Each date and time field is parsed by its kind and written as a typed value, so
native DATE, TIME, TIMESTAMP and TIMESTAMPTZ columns accept it as well
as text columns. A malformed value is a 422 validation error that names the
field.
| Kind (derived from) | Input | Accepted value | Stored in a text column |
|---|---|---|---|
date (NaiveDate) | date | YYYY-MM-DD, year 0001 to 9999 | YYYY-MM-DD |
time (NaiveTime) | time | HH:MM, HH:MM:SS or with a fraction of up to 9 digits | HH:MM:SS[.fraction] |
datetime (NaiveDateTime) | datetime-local | a local YYYY-MM-DDTHH:MM[:SS[.fraction]] (or a space for T) | YYYY-MM-DD HH:MM:SS[.fraction] |
datetime_utc (DateTime<Utc>) | datetime-local, labelled with the panel’s time zone, (UTC) by default | as datetime, read as a wall time in the panel’s time zone; Z, ±HH:MM or ±HH is converted to UTC | RFC 3339, 2026-10-15T09:30:00+00:00 |
A datetime-local input has no time zone, so a datetime_utc field shows and
accepts wall times in the panel’s time zone, which is UTC unless the
application selects another with Nexus::with_time_zone (v13):
use rullst::nexus::{Nexus, NexusTimeZone};
// A fixed offset (no daylight-saving changes), in minutes east of UTC.
let nexus = Nexus::new().with_time_zone(NexusTimeZone::fixed_offset(-180)?);
// An IANA zone with its daylight-saving rules: the `nexus-tz` feature
// (`rullst-nexus/tz`), which uses `jiff` like Core's `scheduler-tz`.
let nexus = Nexus::new().with_time_zone(NexusTimeZone::named("America/Sao_Paulo")?);
The label names the zone (Starts at (America/Sao_Paulo)). An administrator
in São Paulo enters 09:30 and Nexus stores 12:30:00+00:00; the stored
instant is shown converted to the zone whatever offset the database reports
(PostgreSQL’s +00, an RFC 3339 offset, or a MySQL/MariaDB DATETIME holding
UTC). In a zone with daylight-saving changes, a wall time that a gap skips
(clocks move forward) does not exist and answers 422 naming the zone, and a
wall time that a fold repeats (clocks move back) is read as its first, earlier
instant. A stored instant at the later occurrence of a repeated wall time is
shown in a text input as its UTC value (2026-11-01T06:30:00Z), so saving the
form unchanged keeps it; type an offset (2026-11-01T01:30-05:00) to choose
that occurrence. A value either picker cannot hold exactly (finer than
milliseconds, or a wall time outside the years 0001 to 9999) is shown in a text
input. 13.0.0-alpha.2 always used UTC.
The datetime form of a text column is the text CURRENT_TIMESTAMP and SQLx
write, so text comparisons and ordering agree with application rows. A
datetime value with an offset can only come from a text column and is stored
as entered.
In the list, a NULL or undecodable number, relation or Boolean shows a NULL
or unreadable marker rather than 0 or No, and a row whose key is NULL or
cannot be decoded exactly has no batch checkbox or edit/delete actions.
Opening the edit form of a missing, other-tenant or misspelled key returns
404 (and a failed query 500) instead of an empty editable form. The form
reads only the registered visible, non-password columns.
Other form values are bound as text. PostgreSQL has no assignment cast from text,
so there Nexus writes number and integer values through NUMERIC, relation values that
are canonical integers (or empty) through BIGINT, Booleans as untyped
'0'/'1' literals, and dates and times (and their NULL) through DATE,
TIME, TIMESTAMP or TIMESTAMPTZ casts: integer, numeric, floating-point,
BOOLEAN and date/time columns, the INTEGER columns of Blueprint::boolean
and text columns all accept them. MySQL/MariaDB receive a datetime_utc value
as its UTC wall time, which their DATETIME columns hold. Other kinds are
written as text, so keep editable JSON and enum values in text columns; native
JSONB, UUID or enum columns are not supported by Nexus. Lists and edit
forms read date and time fields as text (CAST(... AS TEXT) on PostgreSQL,
CAST(... AS CHAR) on MySQL/MariaDB), so native columns, such as the
timestamps() columns of a strict driver pool, display.
The panel addresses records under /nexus/table/{table}/record/{key} (with
/edit for the form), so a key named new, search or batch never collides
with an action route. The older /nexus/table/{table}/{key} routes remain for
other keys.
Record keys follow the registered primary-key kind: a number, integer (or
relation) key must be a canonical integer, so +1, 01 or 1e3 name no record, and any
other kind is compared as text, even when it looks numeric.
Composite primary keys
A table keyed by several columns, such as lesson_progress (user_id, lesson_id), lists them all, in key order:
#[derive(Debug, Clone, Orm, Nexus)]
#[orm(table = "lesson_progress")]
#[nexus(primary_key = "user_id, lesson_id")]
pub struct LessonProgress {
pub user_id: i32,
pub lesson_id: i32,
pub percent: i32,
}
Annotating each key field with #[nexus(primary_key)] declares the same key
(in field order); a manual NexusModel returns the columns from
nexus_pk_columns(). A key has at most 8 columns, each a registered field
other than the tenant column. Every key column is entered on create (even an
integer one), read-only on edit, and part of every read, update, delete and
batch predicate.
A record is addressed by its key values in key order, each percent-encoded
(every byte except ASCII letters, digits and -_.~) and joined by ,: user 7
and lesson intro are 7,intro, and a text value a,b becomes a%2Cb. The
URL encodes that address once more (/nexus/table/lesson_progress/record/7%2Cintro),
and the audit records it as the record key. Only the canonical spelling with
one value per key column names a record; integer columns still need canonical
integers.
Search matches the typed text literally (% and _ are not wildcards) in the
visible text, textarea, e-mail and URL columns. It is case-insensitive on
PostgreSQL (ILIKE), ASCII case-insensitive on SQLite and follows the column
collation on MySQL/MariaDB. Live search keeps the current sort, starts again at
page 1, rebuilds the sort and pagination links for the new query and records it
in the URL, so saving a record refreshes the same view. A model without such a
visible column shows no search box, and a search query sent to it lists no rows
(with a note) instead of every row.
The list sorts only by the record key and visible, non-password columns; a
sort_by naming a hidden or password column orders by the key instead.
A password field is never displayed: the list shows a fixed mask and the
edit form an empty input, and leaving it empty keeps the stored value. Nexus
writes a new value exactly as typed and does not hash it. Keep hash columns
readonly (or hidden) in Nexus and change them through an application flow
that hashes; a field named password_hash is hidden by the derive.
On an ORM model the derive also follows the #[derive(Orm)] field markers:
#[orm(skip)]and#[sqlx(skip)]fields have no column and are omitted.#[orm(encrypted)]andSecretStringfields are hidden, read-onlypasswordfields: Nexus never lists, searches, sorts, renders or writes them, so an edit cannot store plaintext in an encrypted column. Any other#[nexus(kind/options)]on them is a compile error.#[orm(hidden)]fields are hidden and read-only; only an explicit#[nexus(kind = "password")]exposes one, as a write-onlypasswordfield.#[orm(masked)]fields default to thepasswordwidget; an explicit#[nexus(kind = ...)]deliberately shows them.
id is the default primary key. Use #[nexus(primary_key)] on a field or
#[nexus(primary_key = "uuid")] on the struct for another key, and several
annotated fields or a comma-separated list for a
composite key. An annotated field is the key even
when the struct also has an id field, which then stays an ordinary column;
annotations that differ from the struct-level list are a compile error. A
single integer key is treated as database-generated, so it is hidden and
read-only; any other key (a UUID or code) is listed and entered in the create
form, and stays read-only on edit.
Field options
also include label, hidden, readonly, and the text, textarea, email,
url, number, boolean, date, datetime, time, datetime_utc,
password, json, and enum widget kinds. A hidden field is left out of the list, search, sorting and the
create/edit forms, and a submitted value for it is rejected; readonly keeps a
field visible but rejects submitted values. Implementing NexusModel manually
remains available when an application needs metadata that cannot be derived.
Then select an explicit access policy in your routing file (usually src/lib.rs
or src/main.rs) and mount the resulting router:
let nexus_auth =
rullst::nexus::NexusAuthPolicy::local_development_or_basic_from_env()?;
let nexus = rullst::nexus::Nexus::new()
.with_auth_policy(nexus_auth)
.with_brand("SaaS Admin")
.register::<models::user::User>()
.try_build()?;
// ... and add it to the final router:
let router = router.nest_axum("/nexus", nexus);
The helper is intentionally asymmetric: debug builds allow only requests whose
ConnectInfo peer is loopback (an IPv4-mapped peer such as ::ffff:127.0.0.1,
which a dual-stack :: listener reports for IPv4 clients, counts as IPv4); release builds load and validate
NEXUS_ADMIN_USERNAME and NEXUS_ADMIN_PASSWORD from the process environment,
then the working directory’s .env. Missing connection metadata
is denied, and neither RULLST_ENV nor legacy APP_ENV can turn credential-free access on in a release
binary. Applications can call basic_from_env() directly in debug when testing
the production authentication flow.
Nexus::try_build() returns a plain axum::Router. Mount it on a
rullst::Router with .nest_axum("/nexus", nexus), as above:
rullst::Router::nest accepts only another rullst::Router. In a plain Axum
application, axum::Router::nest("/nexus", nexus) is the equivalent.
rullst::Router has no with_state; pass shared application state with
.layer(Extension(state)) (rullst::web::axum::Extension) and read it in
handlers with the Extension<T> extractor, or mount an axum::Router<S> after
its own with_state; see
sharing application state.
Using the application’s sign-in
An application with its own login can let it decide instead, so an
administrator who is already signed in is not asked to sign in again.
Implement NexusAuthorizer: Nexus calls it on every request, with the
request’s method, URI, headers and extensions, before reading or rendering any
data.
use rullst::nexus::{NexusAccess, NexusAuthPolicy, NexusAuthorizer, NexusAuthorizerError};
use rullst::web::axum::http::request::Parts;
struct Admins;
impl NexusAuthorizer for Admins {
async fn authorize(&self, request: &Parts) -> Result<NexusAccess, NexusAuthorizerError> {
// Look the session up as your auth middleware does.
let Some(user) = current_user(&request.headers).await? else {
return Ok(NexusAccess::SignIn);
};
Ok(if user.is_admin {
NexusAccess::allow(format!("user:{}", user.id))
} else {
NexusAccess::Deny
})
}
}
let nexus = rullst::nexus::Nexus::new()
.with_auth_policy(NexusAuthPolicy::application(Admins))
.register::<models::user::User>()
.try_build()?;
| Answer | Response |
|---|---|
NexusAccess::allow(actor_id) | the request proceeds; actor_id names the administrator in the mutation audit (1 to 255 bytes, no surrounding whitespace or control characters, else 503) |
NexusAccess::SignIn | a page request (GET/HEAD without HX-Request) is redirected (303) to /login; fragments and other methods get 401 with HX-Redirect: /login, which htmx and Nexus’s script follow |
NexusAccess::Deny | 403 |
Err(NexusAuthorizerError::new(reason)) | 503; Nexus logs the reason and never admits the request |
Use NexusAuthPolicy::Application(NexusApplicationAuth::new(Admins).with_login_path("/account/sign-in"))
for another login page; it must be a local path (one leading /, at most 512
visible ASCII characters, no \), or try_build() returns
NexusBuildError::InvalidLoginPath. Because the check runs on every request, a
signed-out or demoted administrator loses access on the next one. The policy
does not need TLS evidence (your session cookie’s Secure flag is the
application’s concern); CSRF protection, the administrator role layer, tenant
scoping and the audit stay as with every policy.
NexusAuthPolicy::local_development_or_application(Admins) keeps the
credential-free loopback policy in debug builds.
The SaaS starter and the LMS example use it. Their src/middlewares/nexus_access.rs
admits the user of the epoch-checked login cookie whose users.is_admin column
is true, so signing out everywhere or a password change also ends Nexus
access. is_admin defaults to false and is declared #[orm(db_default)]:
ORM writes never touch it, so neither a form nor save() grants it, while
User::query().where_eq("is_admin", true) still filters on it. Grant the first administrator with your
database client, for example
UPDATE users SET is_admin = TRUE WHERE email = '[email protected]';; that
administrator can then grant others from the Users model in Nexus. The
users table that cargo rullst auth generates has the same column, ready for
NexusAuthPolicy::application.
Serving the application without Nexus
Because the helper fails closed, a release build without
NEXUS_ADMIN_USERNAME/NEXUS_ADMIN_PASSWORD cannot build Nexus. The
Portfolio and ERP examples then start without /nexus (the ERP, whose back
office shares the Nexus policy, serves only /health and /ready) and print
one line naming the variables; a weak, placeholder or unreadable credential
still stops startup. In your own application, mount Nexus only when its policy
builds:
#![allow(unused)]
fn main() {
#[cfg(feature = "nexus")]
fn router() -> Result<rullst::Router, Box<dyn std::error::Error>> {
use rullst::nexus::{Nexus, NexusAuthPolicy, NexusBuildError};
let router = rullst::Router::new(); // your application's routes
let router = match NexusAuthPolicy::local_development_or_basic_from_env() {
Ok(policy) => {
let nexus = Nexus::new().with_auth_policy(policy).try_build()?;
router.nest_axum("/nexus", nexus)
}
Err(NexusBuildError::MissingCredential { variable }) => {
eprintln!(
"Nexus is not mounted at /nexus: {variable} is not set; set \
NEXUS_ADMIN_USERNAME and NEXUS_ADMIN_PASSWORD to enable it"
);
router
}
// A weak, placeholder or unreadable credential is still a startup error.
Err(error) => return Err(error.into()),
};
Ok(router)
}
}
The debug policy also requires a local Host and, for unsafe methods, a
same-origin Origin. Its responses carry Referrer-Policy: same-origin unless
the application sets one; when a host layer imposes no-referrer, browsers send
Origin: null, which is accepted only with a single Sec-Fetch-Site: same-origin header (page scripts cannot set it).
Basic Auth failures and reverse proxies
Nexus counts failed Basic credentials per client bucket: one IPv4 address or
one IPv6 /64 of the ConnectInfo peer. Five failures in five minutes lock the
bucket for fifteen minutes. The unauthenticated 401 challenge that every
browser receives first is not a failure.
A locked bucket gets 429 without any credential check, so the lockout cannot
confirm a guessed password. The exception is a browser that already logged in
during this process: it received the HttpOnly, Secure
rullst_nexus_known_client cookie and keeps having its credentials checked.
Behind a TLS-terminating reverse proxy every client shares the proxy’s address, so one attacker can lock the bucket for everyone who has no known-client cookie (new browsers, or all browsers after a restart). Configure Core’s trusted-proxy layer with the proxy’s own network to give each client its own bucket:
Server::new(router).trusted_proxies(
TrustedProxyConfig::new(["10.0.0.0/8"])?.trust_forwarded_proto(true),
)
List only the networks your proxies connect from: any host inside a listed
network can choose the client address. Forwarding headers from every other peer
are ignored. With trust_forwarded_proto(true), an X-Forwarded-Proto: https
from that trusted peer also satisfies the Basic Auth TLS requirement, so a
separate NexusVerifiedTls middleware is unnecessary; enable it only when the
proxy overwrites that header. The same settings are available in Rullst.toml
as [security] trusted_proxies, trusted_proxy_header and
trust_forwarded_proto. See the
security architecture
for the resolution rules.
Tenant-scoped administration
Use an explicit tenant column when a registered model contains tenant-owned rows. The column must be a text, non-primary-key field. The derive makes it hidden and read-only so browser form data cannot choose the tenant:
#![allow(unused)]
fn main() {
use rullst::db::{Nexus, Orm};
#[derive(Debug, Clone, Orm, Nexus)]
#[orm(table = "projects", tenant = "organization_id")]
pub struct Project {
pub id: i32,
pub organization_id: String,
pub name: String,
pub active: bool,
}
}
Authentication middleware must resolve membership and install a trusted
rullst::security::TenantContext. Do not construct it directly from
X-Tenant-ID, a query parameter or another client assertion. Nexus applies the
exact scope to list/search/edit/create/update/delete and batch routes (as a
binary-string comparison on MySQL/MariaDB, whose default collations ignore
case); missing context denies a scoped model. A model without the attribute remains global by
design.
#[derive(Nexus)] reads only table (or its ORM alias table_name) and
tenant from a shared #[orm(...)] attribute. Other ORM options, such as
tenant_column, policy, soft_delete(...) or a relation’s foreign_key, are
skipped. ORM tenant isolation (tenant_column) and the Nexus admin scope
(tenant) are separate options; declare both when both are wanted. Fields that
declare an ORM relation (has_many, belongs_to, …) are not table columns
and do not appear in Nexus.
Require transaction-coupled audit
Install the fixed audit schema as an explicit deployment step, then enable the policy on the panel:
rullst::nexus::create_nexus_audit_table().await?;
let nexus = rullst::nexus::Nexus::new()
.with_auth_policy(nexus_auth)
.register::<Project>()
.with_required_audit()
.try_build()?;
Each successful mutation and its minimized rullst_nexus_audits row commit in
one database transaction. Audit failure rolls the mutation back. The record
contains actor, optional tenant, table/action, optional known key (for a create,
the key the administrator entered; a database-generated key is absent), affected-row
count, committed outcome, optional bounded request ID, timestamp and format
version. A key that does not fit 1 to 256 bytes of unpadded text without
control characters is recorded as absent instead of blocking the change. verify_nexus_audit_table() checks deployment readiness and
recent_nexus_audits() reads at most 1,000 newest rows, optionally tenant
filtered; the application must authorize that export separately.
The table is neither append-only nor protected from a database administrator. It does not persist denied attempts, and automatically assigned create keys are not recovered uniformly across all supported SQL dialects. Protect database permissions and send records to an independently operated immutable sink when that property is required.
👤 Example: Dynamic Profile Settings
Applications like the Portfolio example (examples/portfolio) use explicit Nexus metadata to expose
single-row or multi-row site configuration settings (such as developer name,
title, bio, email, personal website, avatar photo, and social links).
#![allow(unused)]
fn main() {
use rullst::db::{Orm, Nexus};
#[derive(Debug, Clone, Orm, Nexus)]
#[orm(table = "profile")]
pub struct Profile {
pub id: i32,
pub name: String,
pub title: String,
pub subtitle: String,
pub email: String,
pub website: String,
pub avatar_url: String,
pub github_url: String,
pub linkedin_url: String,
}
}
When registered in Nexus:
let nexus_auth =
rullst::nexus::NexusAuthPolicy::local_development_or_basic_from_env()?;
let nexus = rullst::nexus::Nexus::new()
.with_auth_policy(nexus_auth)
.with_brand("Portfolio Admin")
.register::<models::profile::Profile>()
.try_build()?;
Administrators can edit the registered profile fields at /nexus. An
application that reads those fields on each request can show the persisted values without a
code change or redeployment; cache policy remains application-owned.
Batch deletion is available for every registered model. Batch deactivation is
shown only when the model declares a writable (neither hidden nor
readonly) Boolean is_active or active field; Nexus never guesses which
arbitrary status value means inactive.
AI assistant
The AI query assistant at /nexus/chat (and its htmx endpoint
/nexus/chat/query) answers questions about the registered models through
the providers rullst-ai reads from the environment (GEMINI_API_KEY,
OPENAI_API_KEY, ANTHROPIC_API_KEY, OLLAMA_HOST, …), and with a built-in
offline schema helper when none is configured. Since 13.0.0-alpha.4 it is an
optional feature:
| Dependency | Feature that enables the assistant |
|---|---|
rullst facade | nexus-ai (implies nexus); nexus alone leaves it out |
rullst-nexus directly | ai, a default feature |
cargo rullst new enables nexus-ai when the SaaS starter is created with
AI (--ai or the wizard’s AI option). Without the feature Nexus does not
depend on rullst-ai (a shorter first build), the panel shows no assistant
link, and /nexus/chat and /nexus/chat/query answer 404 Not Found with a
notice naming the feature to enable; they stay behind the same access policy.
The security and telemetry pages report the assistant as not enabled. With
the feature, routes and behaviour are those of earlier releases:
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["nexus-ai", "strict-sqlite"] }
Content Security Policy
Nexus pages load only same-origin assets from /nexus/assets/ (nexus.css,
nexus.js, a vendored htmx 2.0.4 and the Rullst logo rullst-logo.png,
used as the brand mark and favicon) and contain no inline scripts, styles,
event-handler attributes or hx-on attributes. The default production CSP
applies to the panel unchanged, so there is no reason to add 'unsafe-inline',
'unsafe-eval' or a CDN to the application-wide security.csp. A custom
policy must keep 'self' for scripts, styles, images and connect-src.
Nothing is requested from GitHub, unpkg or Google Fonts; the panel
uses system fonts. htmx’s history cache is disabled, so admin pages and open
edit forms are never snapshotted into origin-wide localStorage; Back reloads
the page from the server.
Benefits of Nexus
- Small Front-end Surface: Nexus renders responsive tables, forms and actions with server-side HTML and HTMX.
- Fail-closed Construction: Nexus cannot build without a selected access policy. Being in the same binary is not itself a security guarantee; the application still owns TLS, trusted proxies, roles, ownership, field policy and database permissions.
- Server-side Field Policy:
hiddenandreadonlymetadata improve the UI, while authorization and write restrictions are also enforced on the server.
In a generated debug application, open /nexus from the same machine. In a
release deployment, configure strong unique credentials and the verified TLS
boundary before exposing the route.
Mobile maintenance in 12.1.0
This section is for applications still on 12.0.x; 12.1.0 and every 13.0 prerelease include these changes.
The Portfolio report, issues 3 and 4 identified a drawer that could not be dismissed on small screens and a desktop-only Portfolio layout. The changes shipped in 12.1.0 and are absent from 12.0.0:
- Nexus supplies a close button, click/touch backdrop, Escape and link dismissal,
focus containment and return, and synchronized
aria-expandedstate. Closed mobile links are inert; the background is inert while the drawer is open. Desktop resizing clears that state. Without JavaScript, ordinary navigation stays visible. The drawer does not require HTMX or a CDN to dismiss. - The Portfolio generator (since 13.0.0-alpha.4 the
examples/portfolioapplication) stacks its sections below 900px, tightens spacing below 640px, wraps long content and keeps project cards inside the viewport. Reduced-motion preferences stop decorative animations.
Update the application dependencies and lockfile to 12.1.0 or later, then
remove the temporary nexus_mobile_patch middleware that buffers and rewrites
Nexus HTML. Retaining it can introduce duplicate close controls,
backdrops and event handlers. Do not remove authentication, authorization, TLS,
CSRF or other security middleware along with that presentation-only patch.
Existing Portfolio projects own their generated src/pages/home.rs: updating
the CLI or dependency alone does not replace that file. Compare its styles
with examples/portfolio/src/pages/home.rs, apply the responsive CSS while preserving your
content/design, and remove conflicting old overrides. Verify narrow screens,
long names/URLs, keyboard focus, touch dismissal, desktop resizing and reduced
motion before redeployment. Other repositories and deployed Azure applications
are not modified by these framework changes.
Rullst Capital: billing boundaries
Vision preserved: capabilities removed from the usable-today contract are still evaluated item by item in the capability ledger, including whether each one is worth implementing and why.
rullst-capital provides provider-neutral billing abstractions, revenue
metrics, verified webhook plumbing and two payment-provider adapters: Stripe
(supported) and InfinitePay (experimental until validated against a live
account). Provider capabilities are not uniform: consult the adapter API and its
tests before depending on a particular checkout, refund or webhook operation.
Other gateways are integrated in application code by implementing the provider
traits; see Writing your own payment provider.
v13 removed the Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce, PicPay and Wise (payout) adapters and the NFS-e fiscal preparation module. NFS-e was never validated with a real municipality and may return as a separate product outside Rullst. See the v13 migration guide row “Capital providers and NFS-e removed”.
RevenueDashboardManager is a bounded process-local presentation source.
Applications call update_metrics with values reconciled from their durable
billing database and call record_event after a verified webhook path. Recording
an event deliberately does not invent a plan price, fee, currency or subscriber
count from its event name.
Payment providers
Initialize only the provider required by the application and treat credentials
as deployment secrets. An empty or mock_* API credential selects the
adapter’s deterministic offline behavior instead of a live request where that
adapter documents support for it, so verify that production credentials are
present. An empty webhook secret is a configuration error; a
mock_* webhook secret selects mock verification, which the production webhook
middleware rejects.
Every reviewed live method uses the same pooled outbound client with a
five-second connect timeout, twenty-second whole-request timeout, disabled
redirects and ambient proxy discovery, and one-MiB JSON limit. Returned
checkout locations must be bounded absolute HTTPS URLs without credentials or
fragments. CapitalError::Provider exposes redacted static provider/operation
labels, failure kind, optional HTTP status and bounded numeric Retry-After.
Its permanent/transient/rate-limited class is evidence for application policy,
not permission to repeat a mutation. Only retry after proving that the exact
operation forwards a persisted idempotency key and retaining reconciliation.
Immediate charges without raw payment data
Billable::charge_with accepts only a provider-tokenized customer and payment
method. Amounts are integer minor units and every attempt needs an
application-owned idempotency key. The reviewed live adapter is Stripe Payment
Intents; other billing adapters fail with UnsupportedOperation instead of
pretending to charge.
#![allow(unused)]
fn main() {
use rullst_capital::{Billable as _, CapitalError, StripeProvider};
async fn collect_order<Account>(
account: &Account,
stripe: &StripeProvider,
) -> Result<(), CapitalError>
where
Account: rullst_capital::Billable + Sync,
{
let receipt = account
.charge_with(
stripe,
4_990, // BRL 49.90 in cents
"BRL",
"cus_provider_owned",
"pm_provider_tokenized",
"order_42-attempt_1",
)
.await?;
if receipt.is_succeeded() {
// Reconcile durable order state; do not grant access from this alone.
}
Ok(())
}
}
The application must establish the customer’s authority over both provider
IDs, retain the key with its order, reconcile signed webhooks and handle
mandate/SCA rules. Empty/mock_* credentials produce a deterministic receipt
with the distinct non-success ChargeStatus::Mock; it must never grant access
or be booked as revenue. This API has no raw-card field and never guesses
currency.
Provider-specific metered usage
Use the static-dispatch MeteredBillingProvider boundary for new code.
StripeMeterEvent requires the authoritative cus_* customer, configured
meter event name, positive quantity, timestamp and a unique visible identifier;
the adapter sends the default stripe_customer_id/value payload and binds all
of those fields in the accepted response.
The response is capped at one MiB and offline credentials return a
deterministic UsageStatus::Mock. Stripe provides only rolling identifier
deduplication. A custom adapter whose provider has no event-key field reports
UsageDeduplication::ApplicationOutboxRequired; the application must then
atomically claim event_key() in a durable outbox before sending. It must also
reconcile invoices and derive quotas/entitlements from authoritative state. The
old uniform BillingProvider::report_usage remains source-compatible for mocks
but fails closed in live Stripe configurations instead of guessing required
fields.
Coupons and relative trial extensions
CouponCode validates/redacts the provider coupon identifier, while a
statically dispatched SubscriptionHandle applies it. Stripe uses the current
expanded discounts[0][coupon] update and binds the response to the requested
subscription/coupon. Adapters without reviewed subscription-discount contracts,
including InfinitePay, return UnsupportedOperation instead of silently
succeeding.
handle.extend_trial(15) resolves a bounded 15-day expiration from the current
UTC clock. Retryable workers should persist the command creation time and call
extend_trial_days_at(15, command_created_at); this emits the same absolute
expiration on every attempt. set_trial_end is available for explicit
reconciliation. Stripe has bounded protocol/response-binding tests for trial
updates. Authorization, command serialization, billing-cycle
policy, webhook reconciliation and live account acceptance remain host/release
responsibilities. See the billing tutorial.
Shared subscriptions and strict resource quotas
One Team/Workspace model can own Billable and its tier policy while every
authorized member uses the same bounded BillingSubject. Derive that subject
from trusted TenantContext, never from a client-selected owner ID.
Billable::quota_request derives the limit from tier_limit; QuotaGate
reserves before invoking a create operation, suppresses an exact retry and
compensates an ordinary callback error.
InMemoryQuotaStore provides deterministic process-local behavior. Enable
quota-sql directly or capital-quota-sql on the umbrella crate for a durable
unique-claim and conditional-counter implementation on SQLite, PostgreSQL,
MySQL and MariaDB. Use reserve_with_transaction and perform the domain insert
in that same transaction whenever quota and creation must commit atomically.
The complete tutorial
shows this path and its replay semantics.
The application still owns membership, authoritative tier/webhook state, migrations, reconciliation of abandoned standalone reservations and adapters for Turso or non-relational stores. Rullst does not intercept writes performed outside the explicit quota boundary.
Payment-bound invoice PDF and delivery
Enable the umbrella capital-mail feature (or Capital’s invoice-pdf and
Mail’s capital-invoice features separately). Invoice::bind_succeeded_charge
rejects non-final/mock receipts and any recipient, minor-unit total or currency
mismatch. PaidInvoiceDelivery::prepare then creates escaped HTML, a bounded
native PDF attachment and a message that has already passed Mail pre-flight.
It can use the configured facade, a tenant route or an explicit static driver.
The returned stable delivery_key is for a unique durable outbox record owned
by the application. Rullst does not infer a webhook, claim that key atomically,
guarantee attachment support at every provider or promise exactly-once
delivery. See the SaaS billing tutorial.
Webhook endpoints must use the Capital verification middleware. Its Axum and opt-in Actix adapters call the same canonical verifier. For supported protocols it performs cryptographic verification, timestamp freshness checks where the protocol signs a timestamp (Stripe), a two-megabyte body limit, and bounded replay protection before the application receives a normalized event. A webhook route may receive a narrowly scoped CSRF exemption only when this verifier remains mandatory on that exact route. See the payment guide for Actix setup.
The default store is process-local. The opt-in webhook-sql feature
(capital-webhook-sql on the umbrella crate) persists
bounded provider-scoped payload digests or stable event IDs across SQLite,
PostgreSQL, MySQL, and MariaDB processes. SQL-backed middleware is a replay
firewall that claims before dispatch, not exactly-once delivery. For an atomic
relational business transition, use the verified provider event ID with
check_and_record_event_key_with_transaction in the domain transaction and do
not pre-claim it through SQL middleware. Cross-system effects still require an
outbox, idempotent consumers, and reconciliation.
#![allow(unused)]
fn main() {
use axum::{routing::post, Router};
use rullst_capital::verify_webhook;
async fn billing_webhook() {
// Read the verified event inserted by the middleware in real handlers.
}
let router: Router = Router::new()
.route("/webhooks/billing", post(billing_webhook))
.layer(axum::middleware::from_fn(verify_webhook));
}
Operational checklist
- Derive tenant identity from an authenticated context, not an arbitrary client header.
- Keep webhook secrets non-empty, rotate them, and retain replay state according to the provider contract.
- Reconcile provider events idempotently in the application database.
- Treat Studio revenue panels as observability views, not an accounting ledger.
- Verify every adapter capability in a sandbox before enabling live traffic.
Integrating AI into Rullst
rullst-ai provides guarded adapters for OpenAI, Anthropic, Gemini, DeepSeek,
Ollama, and explicitly configured OpenAI-compatible local/cloud endpoints. The
high-level AiClient applies prompt-injection heuristics and PII masking before
dispatch. Those controls reduce known risks; passing them does not prove that a
prompt or model response is safe or correct.
Start with the provider capability matrix. It separates implemented transport paths from model-dependent behavior and lists unsupported streaming, tool, timeout, retry, and cancellation boundaries.
1. Enable the AI facade
[dependencies]
rullst = {
version = "=13.0.0-alpha.4",
default-features = false,
features = ["ai"]
}
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Pin the v13 prerelease exactly: Cargo never selects a prerelease on its own,
and the v12 line reached end of life on 16 October 2026. Keep the
application’s Cargo.lock under version control and review future dependency
updates before deployment.
2. Create a guarded client
#![allow(unused)]
fn main() {
use rullst::ai::{AiClient, providers::openai::OpenAiProvider};
fn ai_client() -> Result<AiClient, std::env::VarError> {
let api_key = std::env::var("OPENAI_API_KEY")?;
Ok(AiClient::new(OpenAiProvider::new(api_key)))
}
}
Empty and mock_* keys intentionally select deterministic offline behavior.
Requiring the environment variable, as above, prevents a live deployment from
silently becoming a demo. Tests can construct OpenAiProvider::new("")
explicitly when offline behavior is desired.
Other built-in constructors are available under:
providers::anthropic::AnthropicProvider;providers::gemini::GeminiProvider;providers::deepseek::DeepSeekProvider;providers::ollama::OllamaProvider.
For a local server exposing an OpenAI-compatible endpoint, declare only the request shapes verified for the exact model:
#![allow(unused)]
fn main() {
use rullst::ai::{
AiClient,
providers::openai_compatible::{
OpenAiCompatibleCapabilities, OpenAiCompatibleProvider,
},
};
fn local_client() -> Result<AiClient, rullst::ai::AiError> {
let provider = OpenAiCompatibleProvider::try_local(
"http://127.0.0.1:8080/v1",
"my-local-model",
)?
.with_capabilities(
OpenAiCompatibleCapabilities::chat_only()
.with_embeddings()
.with_json_mode(),
)
.try_with_embedding_model("my-embedding-model")?;
Ok(AiClient::new(provider))
}
}
try_local accepts HTTP only on literal loopback IPs and sends no
authorization header. Use try_local_with_bearer for an authenticated loopback
server or try_cloud(https_base_url, api_key, model) for a Bearer-authenticated
cloud endpoint. None of these constructors discovers model
capabilities; false declarations become provider errors, while omitted
capabilities fail locally with UnsupportedCapability. A non-compatible API
implements the public AiProvider trait instead.
3. Call it from an Axum handler
This bounded handler rejects oversized input before invoking the client and does not expose the provider’s full error details to the HTTP caller:
#![allow(unused)]
fn main() {
use rullst::{Server, ai::AiClient};
use rullst::web::axum::{
Json, Router,
extract::State,
http::StatusCode,
routing::post,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct ChatPrompt {
prompt: String,
}
#[derive(Serialize)]
struct ChatResponse {
answer: String,
}
async fn chat(
State(client): State<AiClient>,
Json(body): Json<ChatPrompt>,
) -> Result<Json<ChatResponse>, (StatusCode, &'static str)> {
if body.prompt.len() > 8_192 {
return Err((StatusCode::PAYLOAD_TOO_LARGE, "prompt is too large"));
}
let answer = client
.prompt(&body.prompt)
.await
.map_err(|_| (StatusCode::BAD_GATEWAY, "AI request failed"))?;
Ok(Json(ChatResponse { answer }))
}
async fn serve(client: AiClient) -> Result<(), rullst::ServerError> {
let app = Router::new()
.route("/api/chat", post(chat))
.with_state(client);
Server::new(app.into()).run(3000).await
}
}
Production applications should authenticate and rate-limit this route, bind tenant identity from authenticated state, cap response sizes, and record an audit event without logging prompts or secrets verbatim.
4. Inspect capabilities before optional operations
#![allow(unused)]
fn main() {
use rullst::ai::{AiClient, EgressFetcher, EgressPolicy, LocalImagePolicy, providers::openai::OpenAiProvider};
async fn example() -> Result<(), Box<dyn std::error::Error>> {
let client = AiClient::new(OpenAiProvider::new("mock_local"));
let capabilities = client.capabilities();
if capabilities.vision {
// Bytes have already crossed the application's own admission boundary.
let png = b"\x89PNG\r\n\x1a\n\x00";
client.prompt_with_image("Describe this image", png).await?;
// Local files must remain under an exact canonical allowlisted root.
let local = LocalImagePolicy::new(["./private-uploads"])?;
client
.prompt_with_image_file("Describe this image", "./private-uploads/photo.png", &local)
.await?;
// URLs require an explicit HTTPS egress allowlist and resource budget.
let remote = EgressPolicy::strict()
.with_allowed_hosts(["images.example.com"])?
.with_max_response_bytes(2 * 1_024 * 1_024)?;
client
.prompt_with_image_url(
"Describe this image",
"https://images.example.com/photo.png",
&EgressFetcher::new(remote),
)
.await?;
}
Ok(())
}
}
Capability inspection prevents avoidable requests but is not a substitute for
handling UnsupportedCapability and upstream model errors. Configuration can
select a model that supports less than its provider transport. File and URL
helpers check capability and guard the text before I/O, cap images at 10 MiB,
sniff JPEG/PNG/WebP/GIF bytes and require a supplied remote Content-Type to
match. The host must authorize the source and protect allowed local directories;
Rullst does not infer tenant ownership from a path or URL.
5. Request structured output
json_prompt requests parseable JSON. It does not claim JSON Schema
enforcement. Use structured_prompt_with_schema only when the reported provider
capability and configured model support it:
use rullst::ai::StructuredOutputSchema;
let schema = StructuredOutputSchema::new(
"answer",
serde_json::json!({
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
"additionalProperties": false
}),
)?;
let answer: serde_json::Value = client
.structured_prompt_with_schema("Summarize the incident", &schema)
.await?;
Validate business rules after deserialization. Schema-conforming model output can still be false, malicious, stale, or unauthorized.
6. Streaming and tools
StreamingAiClient streams guarded, bounded and cancellable (AiCancellation)
output from an OpenAI-compatible configuration that declares with_streaming()
and (new in v13) from Anthropic and Gemini through their native SSE
protocols; StreamSummary::usage() returns the token usage the provider
reported. The OpenAI, DeepSeek and Ollama transports do not stream. Rullst can
host ordinary Axum SSE responses, but an application that uses a third-party
streaming SDK owns its authentication, guardrails, backpressure, deadlines,
cancellation, error mapping, and dependency lifecycle. Do not present that
escape hatch as native rullst-ai streaming.
ToolRegistry stores local tools but is not wired to provider function calling.
Its guarded execution API requires an exact allowlist,
principal authorization, closed JSON validation, size/call limits and an audit
sink. Destructive and Financial tools additionally require a one-use human
approval bound to the exact JSON payload. Treat model output as untrusted input;
the application still authenticates the principal/approver, enforces domain
ownership and supplies durable production auditing.
7. RAG boundary
Rullst supplies prompt construction, an in-memory vector index and the
tenant-bound RagPipeline, which guards every passage, rejects documents tagged
for another tenant and records a minimized audit event (see the
tenant-bound RAG tutorial). Applications
must still enforce document authorization before retrieval, prevent SSRF in any
fetcher, bound document and prompt sizes, identify tenant provenance, and avoid
sending secrets to a provider. Similarity is a ranking signal, not an access
control decision.
8. Durable chat memory
Enable the dedicated SQL memory feature when the application wants the framework-owned fixed schema rather than generated models:
rullst = {
version = "=13.0.0-alpha.4",
default-features = false,
features = ["ai-sql-memory"]
}
The same adapter supports SQLite, PostgreSQL, MySQL, and MariaDB URLs:
#![allow(unused)]
fn main() {
use rullst::{
ai::{
AiClient, ChatMemoryConfig, ConversationId, SqlChatMemory, StatefulChat,
providers::openai::OpenAiProvider,
},
security::TenantContext,
};
async fn chat_service(
database_url: String,
api_key: String,
) -> Result<
(StatefulChat<SqlChatMemory>, TenantContext, ConversationId),
Box<dyn std::error::Error>,
> {
let memory = SqlChatMemory::connect(database_url, ChatMemoryConfig::default()).await?;
memory.prepare_schema().await?;
let service = StatefulChat::new(
AiClient::new(OpenAiProvider::new(api_key)),
memory,
);
let tenant = TenantContext::try_new("tenant-42")?;
let conversation = ConversationId::try_new("support:case-7")?;
service.ensure_conversation(&tenant, &conversation).await?;
Ok((service, tenant, conversation))
}
}
service.send(&tenant, &conversation, text).await loads only the configured
recent message pairs, applies the ordinary AI guardrails, and stores the user
and assistant messages in one transaction. If another process committed from
the same observed revision, the slower StatefulChat call receives
StatefulChatError::Memory(ChatMemoryError::RevisionConflict). Decide
explicitly whether the UI asks the user to retry; the library will not repeat a
potentially billable provider call.
The table stores raw message text. Production code must authenticate
conversation ownership inside the selected tenant, decide encryption and key
management, implement retention/erasure and backup policy, audit provider use
without logging secrets, and manage schema changes through its release process.
Tenant and conversation IDs are case-sensitive on every backend. MySQL/MariaDB
tables created by an earlier release keep their case-insensitive default
collation, because prepare_schema never alters an existing table; they fail
closed for IDs that differ only by case until you apply the
documented migration.
Use cargo rullst make:chat-session instead when you need application-owned
models/migrations or the Turso-primary profile.
AI-friendly architecture and local model endpoints
Rullst favors explicit types, conventional file locations, bounded macros and
generated .llms.txt context. These choices can make a project easier for a
human or coding assistant to navigate, but the repository has no controlled
evidence for a universal token-saving percentage. Prompt size depends on the
task, tool, repository state and model.
What “AI-native” means here
routes!andhtml!provide recognizable syntax boundaries.- Public APIs prefer concrete types and static dispatch where practical.
cargo rullst make:*generates conventional, inspectable source files.cargo rullst generate:ai-contextrecords a compact structural summary.- External provider integrations have deterministic offline behavior for empty
or
mock_*credentials.
This does not guarantee that an assistant understands the application, chooses the right edit, uses fewer tokens or produces secure code. Keep source review, tests and application threat models authoritative.
The AI maintainability and project-building roadmap records the measurable v13 work needed to improve generated agent instructions, bounded task-oriented context and reproducible model evaluations. Those planned evaluation profiles are not current compatibility guarantees.
Ollama through AiClient::auto
The high-level client recognizes OLLAMA_HOST and OLLAMA_MODEL:
export OLLAMA_HOST="http://127.0.0.1:11434"
export OLLAMA_MODEL="llama3"
OLLAMA_HOST is read the way Ollama reads it, so the scheme-less
127.0.0.1:11434 form that ollama serve uses also works: a value without
http:// or https:// means http, and port 11434 unless it names one.
#![allow(unused)]
fn main() {
use rullst_ai::ai::AiClient;
async fn example() -> Result<(), rullst_ai::ai::AiError> {
let client = AiClient::auto()?;
let response = client.prompt("Summarize this bounded input").await?;
println!("{response}");
Ok(())
}
}
AiClient::auto() also recognizes the built-in cloud-provider API-key
variables. With no configured provider it selects a deterministic offline mock;
that fallback is not a live model.
OpenAI-compatible endpoints
An OpenAI-compatible server is configured explicitly rather than inferred from an arbitrary environment variable:
#![allow(unused)]
fn main() {
use rullst_ai::ai::{
AiClient,
providers::openai_compatible::{
OpenAiCompatibleCapabilities, OpenAiCompatibleProvider,
},
};
let provider = OpenAiCompatibleProvider::try_local(
"http://127.0.0.1:1234/v1",
"configured-model-name",
)?
.with_capabilities(OpenAiCompatibleCapabilities::chat_only());
let client = AiClient::new(provider);
Ok::<(), rullst_ai::ai::AiError>(())
}
The same adapter can be used with local runtimes such as llama.cpp server,
LocalAI, LM Studio, or vLLM when the exact installed version and
configuration expose the request shapes declared in Rullst. Those product names
are examples, not compatibility certification; check the runtime’s API and
model documentation. A server with a different protocol implements the public
AiProvider contract instead.
Compatibility must be tested for the methods the application uses. An endpoint
may implement chat while differing on embeddings, vision, JSON Schema, errors
or streaming. Optional request shapes are disabled until explicitly declared.
Use try_local_with_bearer for an authenticated loopback server and
try_cloud for HTTPS/Bearer cloud endpoints. Consult the provider capability
matrix.
Measuring token usage (v13)
The v13 prerelease rullst-ai reports the token counts a provider returns
instead of estimating them. ChatBuilder::send_with_usage (or
AiProvider::chat_with_usage) returns a ChatCompletion whose optional
TokenUsage carries input, output, total and cached-input counts, and
StreamSummary::usage() returns the counts reported at the end of a stream:
#![allow(unused)]
fn main() {
use rullst_ai::ai::AiClient;
async fn example() -> Result<(), rullst_ai::ai::AiError> {
let client = AiClient::auto()?;
let completion = client
.chat()
.user("Summarize this bounded input")
.send_with_usage()
.await?;
if let Some(usage) = completion.usage() {
println!("input: {:?}, output: {:?}", usage.input_tokens(), usage.output_tokens());
}
Ok(())
}
}
A count the provider does not send stays None, offline mocks report none, and
Rullst ships no price table. The capability matrix
lists the response fields read for each provider.
Privacy boundary
Using a loopback endpoint can avoid sending model requests to a cloud provider, but it does not prove an air gap or zero leakage. The model runtime, host network, proxy variables, logs, tracing, crash dumps and application code still determine the real data path. The compatible adapter ignores ambient proxies and redirects, but that is only one transport boundary. The built-in prompt and PII checks are bounded heuristics, not authorization or a complete data-loss-prevention guarantee.
Redis, Local Cache & Queue Drivers
Rullst keeps cache and queue backends explicit. Enabling a Cargo feature
only compiles the adapter; it does not inspect REDIS_URL, switch drivers, or
silently fall back when Redis is unavailable.
Cache choices
Cache::memory() uses a process-local DashMap. Values disappear on restart
and are not shared between replicas. A TTL too large for the monotonic clock
to represent, such as u64::MAX, is stored as “never expires” instead of
panicking:
#![allow(unused)]
fn main() {
use rullst_core::cache::{Cache, CacheError};
use std::sync::Arc;
async fn load_profile(cache: &Cache) -> Result<Arc<String>, CacheError> {
cache
.remember("profile:42", 300, || async {
Ok("serialized profile".to_string())
})
.await
}
async fn read_profile() -> Result<(), CacheError> {
let cache = Cache::memory();
let profile = load_profile(&cache).await?;
let _ = profile;
Ok(())
}
}
For a shared Redis cache, enable cache-redis (or the umbrella redis
feature) and construct the adapter explicitly:
[dependencies]
rullst-core = { version = "=13.0.0-alpha.4", features = ["cache-redis"] }
#![allow(unused)]
fn main() {
use rullst_core::cache::Cache;
async fn cache_featured_catalog() -> Result<(), Box<dyn std::error::Error>> {
let redis_url = std::env::var("REDIS_URL")?;
let cache = Cache::redis(redis_url)?;
cache.put("catalog:featured", "[...]", Some(600)).await?;
Ok(())
}
}
A zero TTL removes the key, and a TTL beyond Redis’s expiry range is stored
without expiry, as in the memory driver. Constructing the driver validates the
Redis URL but does not establish a connection. The first operation opens one
multiplexed async connection that later operations share; it is not reopened
per command. When that connection breaks, the failing operation returns a typed
CacheError and the next one reconnects. Operations also return CacheError
if Redis is unavailable. Choose an application-specific policy: fail startup,
retry with bounds, or explicitly select Cache::memory() for a documented
single-instance development mode.
The built-in Redis cache prefixes keys with rullst:cache:. flush() scans and
unlinks keys under that prefix; use dedicated credentials/database boundaries
when multiple applications share a Redis service.
ORM .remember(...) queries
The ORM has a separate opt-in query-cache contract behind its redis feature:
[dependencies]
rullst-orm = { version = "=13.0.0-alpha.4", features = ["redis"] }
The generated .remember(...), cache invalidation, orm:events:* publications
and Redis hash helpers follow this ORM feature (or the facade’s redis /
orm-redis). From 13.0 the application does not declare a redis feature of
its own; earlier macro output checked the application’s features instead.
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(table = "users")]
struct User {
id: i32,
active: bool,
}
async fn load_recent_users() -> Result<(), Box<dyn std::error::Error>> {
let redis_url = std::env::var("REDIS_URL")?;
Orm::init_redis_with_namespace(&redis_url, "academy-production").await?;
let recent = User::query()
.where_eq("active", true)
.remember(30)
.get()
.await?;
let _ = recent;
Ok(())
}
}
Generated save_to_redis/get_from_redis/increment_redis_field model
hashes use the same namespace, and tenant models bind the active tenant into
their key and require with_tenant(...). Hashes that 12.0 and 12.1 wrote under
orm:<table>:<id> are handled as described in
Model hashes stored by 12.0 and 12.1.
Use a stable, unique namespace for every application that shares a Redis
database. Query keys bind that namespace, the table, generated SQL and typed
bindings and, for a model with a tenant_column, an opaque digest of the
active tenant; other models share one global entry. They do not expose raw
tenant identifiers. Entries live under rullst:orm:cache:v4:<namespace>: and
model hashes under rullst:orm:hash:v1:<namespace>:. Cache entries written by
earlier versions are never read again and expire through their TTL. The older
Orm::init_redis(url) API remains available and uses default; only use it
with a dedicated Redis database.
The failure and consistency rules are explicit:
remember(0)is rejected.- Missing Redis initialization is a configuration error for a remembered query outside a transaction.
- Redis command failures or corrupt JSON fall back to the authoritative database; a successful read is returned even if cache population fails.
- Explicit and task-scoped ORM transactions always bypass query cache.
- Generated model saves/deletes invalidate the table’s global entries and, for
a tenant-scoped model, those of the tenant active at the write, only after
commit; rollback keeps existing cache entries. Each write follows the
table’s key index (at most 10,000 live keys), never a keyspace
SCAN. Raw SQL, bulk builders and writes from another process cannot be inferred. Keep defensive TTLs and do not cache authorization or reads that require a stronger distributed consistency contract.
The Core Cache facade and ORM query cache use different keyspaces and APIs;
initializing one does not initialize the other.
Model hashes stored by 12.0 and 12.1
Releases up to 12.1 stored every generated model hash under the shared key
orm:<table>:<id>. For a model without a tenant scope, get_from_redis reads
that key while the namespaced hash is missing, and the next save_to_redis or
increment_redis_field moves it to the namespaced key in one Redis script
(every field is kept; an existing namespaced hash wins and the stale legacy
hash is removed). Nothing has to be run. Applications that share one Redis
database also shared these keys, so the first one to write a hash takes it
over. A 12.x hash of a model with #[orm(encrypted)] fields holds them in
plaintext, so reading it fails closed until save_to_redis() rewrites it.
Tenant models never read, move or delete the 12.x key, which every tenant
shared, so until migrated get_from_redis returns None and
increment_redis_field starts from zero. Migrate them once after deploying
13:
- List the keys of each tenant model table:
redis-cli --scan --pattern 'orm:<table>:*'. - Read each hash with
HGETALL; every value is the JSON of one field. - Check its tenant column against the tenant that owns row
<id>in the database, and skip mismatches: another tenant may have overwritten it. - Decode the hash into the model (for example with
serde_jsonfrom the parsed values), or reload the row when the hash only cached it, and callwith_tenant(tenant, model.save_to_redis()). - Remove the 12.x key with
UNLINK.
Queue choices
Rullst provides explicit SQLite and Redis queue constructors:
[dependencies]
rullst-core = { version = "=13.0.0-alpha.4", features = ["queue-sqlite"] }
serde_json = "1"
#![allow(unused)]
fn main() {
use rullst_core::queue::Queue;
use serde_json::json;
async fn enqueue_receipt() -> Result<(), Box<dyn std::error::Error>> {
let queue = Queue::sqlite("sqlite://jobs.sqlite?mode=rwc").await?;
let job_id = queue
.dispatch("send_receipt", json!({ "invoice_id": 42 }))
.await?;
println!("queued {job_id}");
Ok(())
}
}
With queue-redis, construct Queue::redis(redis_url) instead. The Redis
driver uses atomic Lua transitions for pending, processing, failed, and
dead-letter state. Failed jobs (with their payloads) and dead letters are each
retained up to 10,000 entries; recording one more evicts the oldest in the same
script. RedisDriver::try_with_failure_retention(failed_jobs, dead_letters)
accepts 1–100,000 for each (pass the configured driver to Queue::custom).
Failed jobs recorded before this bound was introduced (for example by 12.x
workers) are not indexed, so they are neither counted, listed nor evicted;
retry_failed_job still finds them by ID and purge_failed_jobs removes them.
list_all_jobs returns at most 1,000 rows, failures and dead letters first,
and list_job_previews (new in v13) returns the same rows with each
payload and error cut to a byte budget inside one Lua script;
retry_failed_job moves a failed job back to the pending list with its attempt
counter intact; purge_failed_jobs deletes every failed job and dead letter. Like the cache, each driver shares one lazily opened
multiplexed connection and reconnects after a failed operation. Production validation must still cover Redis persistence,
eviction policy, credentials/TLS, failover, monitoring, and worker recovery in
the target topology.
There is no automatic interchange between the SQLite and Redis queues: they store independent state. Switching a live deployment requires an explicit drain/migration plan.
Real-time boundary
Core’s current WebSocket broadcast/presence helpers are process-local. They
release channels without subscribers and empty presence rooms, so the registry
tracks live rooms rather than every name ever used. Redis Pub/Sub, Kafka, and
RabbitMQ real-time transports remain roadmap work; do not describe the cache or
queue adapter as cross-instance real-time sync. The v13
Redis Streams messaging candidate in rullst-messaging
is at-least-once brokered delivery, not WebSocket fan-out.
Deployment checklist
- Choose the backend in application configuration and make fallback policy explicit.
- Never commit Redis credentials; prefer TLS and least-privilege network access.
- Namespace application/tenant keys above the built-in driver prefix where
isolation is required.
TenantCachesupplies validated tenant namespaces astenants:<tenant>:<key>, where%and:in the tenant segment are written as%25and%3Aso a key containing:cannot reach another tenant. - Test disconnects, timeouts, retries, eviction, restart, and worker recovery.
- Benchmark the deployed service. Rullst does not claim universal cache latency, memory usage, or infrastructure cost.
Threat Radar and security telemetry
Vision preserved: external intelligence, verified audit feeds, durable telemetry, and SOC ambitions remain itemized with an implementation opinion in the capability ledger.
Rullst exposes development dashboards for security events in Studio and Nexus. They are observability surfaces, not a managed SOC, a penetration test, or proof of compliance.
The current dispatch_siem_alert compatibility facade records a bounded local
event; it does not transmit or acknowledge delivery to an external SIEM. The
dashboards therefore label this metric as local SIEM-candidate alerts.
Surfaces and access
- Studio mounts its security view under
/studio/security. - Nexus exposes its administrative security view only inside the authenticated, authorized Nexus router.
Both surfaces contain sensitive operational information. Bind Studio to a trusted interface and protect any deployed administrative route with TLS, authentication, role checks, and rate limiting.
What the counters mean
The dashboards read counters and bounded recent events from the in-process
SecurityStore. A value increments only when the corresponding middleware or
helper is installed and records an event. A zero means “nothing recorded by this
process”, not “the application is proven attack-free”. Restarting a process may
reset in-memory state unless the application exports it to durable telemetry.
Typical sources include:
- honeypot route hits and active TTL-limited bans;
- sanitizer, DLP, CSRF/CSWSH, RBAC, rate-limit, and login-jail events;
- prompt-injection filtering and PII masking events;
- secure-header applications and timing-guard executions.
Client identity comes from the trusted peer by default. Forwarded headers are
usable only when the application has explicitly configured a trusted proxy
boundary, such as Core’s Server::trusted_proxies listing only the proxy
networks.
Audit-chain status
AuditChain signs canonical, length-delimited records with HMAC and can verify
record integrity and sequence continuity. It detects modifications only when the
key is protected and the complete sequence is retained. A writer that restarts
must resume from the newest persisted record with AuditChain::try_resume
(new in v13); a fresh chain restarts at genesis and the retained trail no
longer verifies as one sequence. One writer must own each persisted chain.
The dashboard must display Unavailable until an audit source and continuity
verifier are actually connected. It must never infer “verified” merely because an
event was emitted. An HMAC chain is tamper-evident; it cannot prevent deletion,
key compromise, or loss of the entire log.
Security controls and limits
| Control | What it provides | Important limit |
|---|---|---|
| RASP/WAF inspection | Bounded heuristics for common malicious request patterns. | It is not a complete parser or substitute for parameterization and domain validation. |
| Secure headers | A strict nonce-based CSP/header baseline. | The deployed page, proxy, browser, and policy determine scanner results; no A+ grade is guaranteed. |
| Honeypots | Exact synthetic trap paths and temporary bans of direct requests. A load a page initiated (fetch metadata other than Sec-Fetch-Site: none, or Origin/Referer without it) is refused and recorded without a ban. | They do not identify every scanner or replace edge rate limiting. A scanner can avoid the ban by sending those headers, and a browser that sends neither fetch metadata nor a referrer cannot be told apart from a scanner. |
| Login guard | Shared failure tracking, delay, cleanup, and jail policy. | Account and recovery policy remain application responsibilities. |
| DLP and PII filters | Supported textual-response masking with content-type and size safeguards. | Binary, encoded, streaming, and unsupported responses must follow explicit fail-open/fail-closed policy. |
| AI guardrails | Prompt checks and masking in the high-level AI client. | Heuristics cannot guarantee that every adversarial prompt or sensitive value is detected. |
Local verification
Use a test-only application instance and assert both the HTTP response and the recorded event. Do not probe a third-party or production target without written authorization.
Recommended negative tests include:
- exact honeypot paths versus innocent paths containing similar substrings;
- spoofed forwarding headers from untrusted peers;
- ban expiry and bounded-cardinality behavior;
- valid, invalid, reordered, and truncated audit sequences;
- JSON, HTML, binary, compressed, SSE, streaming, and oversized DLP responses;
- CSP nonces that match the rendered response and differ between requests;
- authorization checks for every Nexus CRUD and batch operation.
Production checklist
- Mount every security layer required by the application; availability in a crate does not install it automatically.
- Configure trusted proxies explicitly and retain the direct peer address.
- Export logs and metrics to durable, access-controlled storage.
- Protect audit keys separately from audit records and rotate them with a documented verification procedure.
- Test application-specific CSP, CSRF exceptions, tenant boundaries, and authorization rules.
- Keep Studio private and verify Nexus authentication, TLS, rate limiting, and admin-role enforcement end to end.
Rullst Telemetry, Spans & Process Observability 📡
Rullst exposes three related but separate observability surfaces:
RadarSnapshotsamples supported process and Tokio runtime data;radar_metrics_router()exposes those samples in Prometheus text format;SpanCollectoris a bounded, process-local buffer for spans that application or framework code records explicitly.
The optional telemetry Cargo feature also installs an OpenTelemetry tracing
layer. None of these components proves a performance target or replaces a
durable production observability backend.
Rullst 13 (since 13.0.0-alpha.1) adds an explicit, minimized cross-process profile and repairs legacy OTLP transport initialization. See distributed operation tracing for parent trust, approved labels, owned shutdown, actual collector tests and admission status.
Process and Tokio observations
RadarSnapshot::collect_async() measures one scheduler yield and samples the
probes available on the current platform:
#![allow(unused)]
fn main() {
use rullst_core::radar::RadarSnapshot;
async fn inspect_process() {
let snapshot = RadarSnapshot::collect_async().await;
println!("uptime: {}s", snapshot.uptime_seconds);
println!("rss: {:?} MB", snapshot.memory_rss_mb);
println!("cpu: {:?}%", snapshot.cpu_usage_percent);
println!("tokio tasks: {:?}", snapshot.active_tokio_tasks);
println!("yield observation: {:?} us", snapshot.tokio_latency_micros);
}
}
The option-valued fields are deliberately None when a real probe is not
available. Linux and Windows provide the current RSS/CPU implementations;
active-task data requires a Tokio runtime. A yield observation is not a complete
event-loop latency distribution.
Prometheus endpoint
Mount the metrics router explicitly:
#![allow(unused)]
fn main() {
use axum::Router;
use rullst_core::radar::radar_metrics_router;
let app = Router::new().merge(radar_metrics_router()); // GET /metrics
}
Only available metrics are emitted. The exporter formats a point-in-time local snapshot; authentication, network exposure, scraping, retention, dashboards, alerts, and multi-instance aggregation belong to the deployment.
Bounded local span collector
The global collector holds at most 500 TraceSpan records in memory. Recording
is explicit; merely constructing a server does not instrument every HTTP, SQL,
AI, mail, or security operation.
#![allow(unused)]
fn main() {
use rullst_core::telemetry_spans::{TraceSpan, global_span_collector};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
let started = Instant::now();
// Run the operation being observed.
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
global_span_collector().record(TraceSpan {
name: "catalog.refresh".to_string(),
kind: "job".to_string(),
duration_us: u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX),
timestamp,
});
}
Studio’s /studio/traces and /studio/radar pages display the records that are
actually present in this process. The buffer is not distributed, persistent,
or a parent/child tracing model.
OpenTelemetry export
The legacy initializer below uses the OTLP transport repaired in v13. Prefer the owned profile linked above when you need explicit metadata policy and flush/shutdown. Enable the feature with the exact prerelease version (Cargo never selects a prerelease on its own):
[dependencies]
rullst-core = { version = "=13.0.0-alpha.4", features = ["telemetry"] }
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://127.0.0.1:4318/v1/traces
RUST_LOG=info
Then initialize the tracing subscriber once at startup:
#![allow(unused)]
fn main() {
fn initialize() -> Result<(), Box<dyn std::error::Error>> {
rullst_core::telemetry::init_telemetry()?;
Ok(())
}
}
Server::run also attempts this initialization, but an application that needs
to fail closed on telemetry configuration should initialize it explicitly and
handle the returned error before starting the server. The current exporter uses
OTLP over HTTP and the service resource name rullst-app.
The generic OTEL_EXPORTER_OTLP_ENDPOINT instead denotes a base URL to which
the initializer appends /v1/traces; do not put a gRPC endpoint in either value.
Without RUST_LOG (or with one that does not parse), the subscriber that
Server::run and init_telemetry install uses info,sqlx=warn: SQLx’s own
connection and statement events, such as acquired connection, appear only
from WARN up. To see them again, set RUST_LOG=info,sqlx=info (or
sqlx=debug); any RUST_LOG replaces the default as a whole.
Health probes in the access log
Server prints one [HTTP] GET /orders -> 200 (1.20 ms) line per request. In
staging and production it skips /health and /ready requests that answer
below 400, so orchestrator probes every few seconds do not fill the log; a
failing probe (for example /ready -> 503 while draining) is still printed.
Set RULLST_LOG_HEALTH_PROBES=true in the process environment or .env to
print every probe again, or false to skip successful probes in development
too.
RedactPersonalDataLayer detects a small list of sensitive field names and
emits a warning. It cannot rewrite a tracing event already observed by another
layer, so secrets must be removed or redacted at the call site.
Development dashboard endpoint
cargo rullst dash shows live request, latency, error, ORM and queue figures
from GET /_rullst/dev-telemetry (v13). Server mounts this endpoint next to
the development reload routes, so it exists only when all of these hold:
- a debug build (
debug_assertions); - the Development environment (
RULLST_ENV, legacyAPP_ENVor[app].env; see Tutorial 09); - a valid
RULLST_DEV_GENERATION, which thecargo rullst dev/dashsupervisor sets for the process it starts.
Release builds, Staging, Production and an application started with
cargo run never mount it. The endpoint answers only a direct loopback peer
that addresses the server with a loopback Host (localhost, 127.0.0.0/8 or
[::1]) and, when present, a loopback Origin, over HTTP/1.1 or newer and
without a forwarding header (Forwarded, X-Forwarded-For,
X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Server, X-Real-IP,
Via, CF-Connecting-IP or True-Client-IP); any other request receives an
empty 404. This rejects other machines, clients resolved through trusted
proxies, DNS-rebinding pages and the common same-host reverse proxies and
tunnels (Apache mod_proxy, Caddy, Traefik, ngrok, cloudflared and nginx’s
default HTTP/1.0 upstream), which connect from loopback and may rewrite Host
to a loopback address. A same-host proxy that speaks HTTP/1.1, rewrites Host
and adds none of these headers is indistinguishable from a local client, so do
not publish a development server through one. This is a local development
boundary, not authentication. Responses carry Cache-Control: no-store and
X-Content-Type-Options: nosniff. Like the reload poll, the dashboard’s poll
bypasses the rate limiter and Traffic Shield and is neither access-logged nor
counted.
The rullst.dev-telemetry.v1 document contains:
http: request, 4xx and 5xx counters since the process started and the newest 64 requests (sequence number, method, path without query string, status, duration in microseconds). They are recorded by the outermost layer, so a handler panic that the development error console answers with500and the responses of the security baseline (for example a CSRF403), lifecycle admission, the rate limiter (429) and Traffic Shield are counted too. Like the access log, it skips the development polls and the files served from the framework’s/staticdirectory;database: ORM operation counters and the newest 16 operations that took at least 100 ms, with their static model, table and operation labels, orunavailablewithsubscriber_not_installedororm_spans_filtered. An observed database also reports possible N+1 queries (v13):repeated_threshold(3),repeated_queries_totaland the newest 16recent_repeatedfindings, each one ORM operation fingerprint that a single request ran at least that many times, with the request’s method and matched route template (or its path when no route matched);queue: the pending count of the queue passed toServer::with_dev_queue,not_configured, orunavailablewithtimeout(250 ms) ordriver_error.
{
"schema": "rullst.dev-telemetry.v1",
"generation": "0123456789abcdef0123456789abcdef",
"uptime_ms": 5120,
"http": {
"requests_total": 2,
"client_errors_total": 0,
"server_errors_total": 1,
"recent": [
{"seq": 1, "method": "GET", "path": "/", "status": 200, "duration_us": 912},
{"seq": 2, "method": "POST", "path": "/orders", "status": 500, "duration_us": 48210}
]
},
"database": {
"state": "observed",
"queries_total": 3,
"slow_queries_total": 1,
"slow_threshold_ms": 100,
"recent_slow": [
{"seq": 1, "operation": "select_many", "model": "Order", "table": "orders", "duration_us": 152004}
],
"repeated_threshold": 3,
"repeated_queries_total": 1,
"recent_repeated": [
{"seq": 1, "method": "GET", "route": "/orders/{id}", "fingerprint": "LineItem.find (line_items)", "occurrences": 12}
]
},
"queue": {"state": "observed", "pending": 4}
}
Request and response bodies, headers, cookies, query strings, SQL text, bindings and error messages are never recorded. Paths appear as the access log already prints them, so a path segment that carries a secret is visible here too. Recording starts only after the endpoint is mounted; until then each request costs one atomic load. Methods are cut to 16 bytes, paths to 256 bytes and labels to 64 bytes, and control characters are replaced.
ORM figures come from the existing secret-free rullst.orm.query spans.
telemetry::init_telemetry (which Server::run calls) adds a passive layer
that times the outermost such span of each ORM operation, from creation until
it closes. Since 13.0.0-alpha.3 the layer is installed in release builds too,
for the production monitoring endpoint; outside a
recording scope it only compares the span’s name; nested operations such as eager loads belong to their
outer operation. The span of a chunk/chunk_by_id traversal also covers the
application’s handler, so it is neither counted nor treated as enclosing: each
page it fetches and each operation the handler runs is counted on its own. An
application that installs its own global subscriber first, or a RUST_LOG that
disables rullst_orm INFO spans, receives the unavailable state instead of
misleading zeros. Statements executed directly
through SQLx are not observed.
N+1 findings use the shared rule in rullst_core::query_patterns, which
Studio’s trace view also applies: the same fingerprint at least
N_PLUS_ONE_THRESHOLD (3) times within one request. The recording layer runs
each request in a task-local scope; an outermost ORM operation created on that
task adds its fingerprint (Model.operation (table), built from the static
span labels, so no SQL text or literal value is involved). Operations without
a model or table label, such as Orm::raw statements, are not fingerprinted
because different statements share one label, and operations started on
another task (for example inside tokio::spawn) are not attributed to the
request. Both cases can only hide a repetition, never invent one. A finding is
a heuristic: repetition can be intentional.
Report a queue’s pending count, read with a 250 ms limit on each poll:
#![allow(unused)]
fn main() {
use rullst_core::{Queue, Server, routes, routing::get};
use std::sync::Arc;
async fn run(queue: Queue) -> Result<(), Box<dyn std::error::Error>> {
let queue = Arc::new(queue); // also shared with the application's workers
Server::new(routes![get("/" => || async { "OK" })])
.with_dev_queue(queue.clone())
.run(3000)
.await?;
Ok(())
}
}
The setting has no effect outside a supervised debug Development process.
Studio boundaries
- Radar cards poll the local
/api/radarendpoint and displayUnavailableinstead of fabricated values. - Local span pages reflect only the in-memory collector in the Studio process.
- Studio binds to loopback by default and has no built-in shared-environment password mode. Do not expose it publicly without an authenticated boundary.
- Measure collector/export overhead against the real application workload and release build; Rullst publishes no universal latency or memory number.
Production monitoring endpoint
Rullst Studio’s local pages are a development tool: they run only in debug
builds, on 127.0.0.1:5555, and read the local process. To watch an application in
staging or production, Rullst 13 (since 13.0.0-alpha.3) adds a read-only
monitoring endpoint to the application itself:
GET /_rullst/monitor/v1
It answers a JSON document, versioned as rullst.monitor.v1, with request
rates and latency per route pattern, status classes, queue depth and worker
activity, possible N+1 queries and the process radar. It never contains
database rows, request data or configuration. It is phase 1 of the
Studio production monitor plan;
phase 2 is the standalone Studio on your
machine, which reads this endpoint for several projects. Any HTTPS client
(curl, a script, an uptime tool) can read it too.
The endpoint is off by default. The threat model is
TM-STUDIO-2.
Enable it
Two things are needed: the application mounts the monitor, and the operator gives it a credential.
use rullst::Server;
use rullst::monitor::Monitor;
use rullst::routes;
use rullst::routing::get;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
Server::new(routes![get("/" => || async { "OK" })])
.with_monitor(Monitor::new().with_application(
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
))
.run(3000)
.await?;
Ok(())
}
Then generate a token of at least 32 random bytes and set it where the server
reads its configuration, the process environment or .env:
openssl rand -base64 32 # or: openssl rand -hex 32
RULLST_MONITOR_TOKENS=<the generated value>
When the server starts it validates every
token: fewer than 43 base64/base64url or 64 hexadecimal characters, other
characters, fewer than ten distinct characters or a placeholder such as
changeme stop start-up with a configuration error that names the token’s
position, never its value. Without any token and without an application
authorizer, with_monitor mounts nothing: /_rullst/monitor/v1 answers 404
and requests are handled exactly as without the monitor. So the code can stay
in place and the operator decides per deployment.
Monitor::with_token adds a token from your own secret store, in addition to
the environment’s.
Projects generated by cargo rullst new with the Blank, API or SaaS starter
(and the LMS example) already carry these lines in src/main.rs, commented out
in the server chain, and list # RULLST_MONITOR_TOKENS= in .env and
.env.example, also commented out. To enable the monitor, uncomment the
.with_monitor(...) call, generate a token and set RULLST_MONITOR_TOKENS in
the deployment’s environment (keep real tokens out of .env.example). Until
then nothing is mounted. Projects generated earlier, and the ERP and Portfolio
examples, add the call as shown above.
Read it with the token in the Authorization header, never in the URL:
curl -fsS -H "Authorization: Bearer $RULLST_MONITOR_TOKEN" \
https://shop.example/_rullst/monitor/v1
Use the application’s own sign-in
An administrator who is signed in to the application can read the endpoint
with their session instead of a token, the way Nexus accepts
NexusAuthPolicy::application. Implement MonitorAuthorizer:
#![allow(unused)]
fn main() {
use rullst::http::request::Parts;
use rullst::monitor::{Monitor, MonitorAccess, MonitorAuthorizer, MonitorAuthorizerError};
/// Admits requests an outer session layer marked as coming from operators.
struct Operators;
impl MonitorAuthorizer for Operators {
async fn authorize(&self, request: &Parts) -> Result<MonitorAccess, MonitorAuthorizerError> {
Ok(match request.headers.get("x-verified-operator") {
Some(_) => MonitorAccess::Allow,
None => MonitorAccess::SignIn,
})
}
}
let monitor = Monitor::new().with_authorizer(Operators);
}
The authorizer is asked for every request without a valid token. Allow
serves the document, SignIn answers 401, Deny (a signed-in user without
the permission) answers 403, and an error answers 503 and logs the
reason. Browsers cannot read the endpoint from another page; see below.
Report queues
#![allow(unused)]
fn main() {
use rullst::monitor::Monitor;
use std::sync::Arc;
fn monitor(queue: Arc<rullst::Queue>) -> Monitor {
// The same Arc<Queue> the application's workers use.
let monitor = Monitor::new().with_queue("default", queue.clone());
monitor
}
}
Up to 16 queues, named with 1 to 64 letters, digits, ., _ or -.
Put it behind your proxy or VPN
In staging and production the endpoint refuses plain HTTP with 426 Upgrade Required, before it looks at credentials. A request is accepted when:
- a reverse proxy listed in
Server::trusted_proxies(or[security] trusted_proxies) withtrust_forwarded_protoreportedhttps; - the application terminates TLS itself and inserts
MonitorVerifiedTls::from_trusted_tls_termination()into the request’s extensions from its TLS listener; or Monitor::allow_loopback_plaintext(true)is set and the request comes directly from a loopback address, names a loopbackHostand carries no forwarding header: an SSH tunnel (ssh -L 9000:127.0.0.1:3000 server) or a sidecar on the same host.
A forwarded header from any other peer, or an absolute https:// request
target, is not evidence. If a token ever crossed a network in plain text,
rotate it.
Exposure is your choice. The safest setups keep the path off the public internet: allow it only from your VPN (WireGuard, Tailscale) or office ranges in the reverse proxy, for example with nginx:
location /_rullst/monitor/ {
allow 100.64.0.0/10; # Tailscale
deny all;
proxy_pass http://127.0.0.1:3000;
}
Rotate tokens
RULLST_MONITOR_TOKENS holds up to eight comma-separated tokens, and any of
them is accepted. To rotate without downtime:
- add the new token:
RULLST_MONITOR_TOKENS=<new>,<old>, and restart or redeploy (tokens are read at start-up); - switch your monitoring clients to the new token;
- remove the old one and restart again.
Give each client (each developer machine, each script) its own token, so you can revoke one by removing it.
Responses
| Status | When |
|---|---|
200 | Document served (Content-Type: application/json). |
401 | No credential, or an invalid one: always the same empty answer, with WWW-Authenticate: Bearer realm="rullst-monitor". |
403 | The request carries an Origin header or a Sec-Fetch-Site of cross-site/same-site (a browser page), or the authorizer denied it. |
404 | The monitor is not mounted. |
405 | Any method other than GET/HEAD. |
426 | Staging or production without TLS evidence. |
429 | More than 30 requests in a burst (one more each second) from one client, with Retry-After: 1; or, after 10 invalid presented tokens, every request from that client until one more attempt is allowed every 90 seconds (Retry-After: 90), without evaluating credentials. |
503 | The application authorizer failed. |
Every answer of the mounted endpoint carries Cache-Control: no-store and
X-Content-Type-Options: nosniff; staging and production also add the usual
security headers. The endpoint adds no CORS headers, and because it refuses
any request with an Origin, an application CORS allowlist never releases it
to a browser page. Clients are identified per IPv4 address or IPv6 /64, as
resolved through trusted proxies: configure them, or every client behind your
proxy shares one budget and one lockout. These limits are per process. The
server’s own rate limiter and Traffic Shield apply to the endpoint like any
route, and the endpoint’s own requests are not counted in its statistics.
What it reports
{
"schema": "rullst.monitor.v1",
"generated_at": 1792648800,
"application": { "name": "shop", "version": "1.4.0" },
"framework": { "name": "rullst", "version": "13.0.0-alpha.4" },
"uptime_seconds": 86400,
"process": {
"memory_rss_mb": 48.2,
"cpu_usage_percent": 3.5,
"active_tokio_tasks": 42,
"tokio_latency_micros": 18
},
"http": {
"window_seconds": 300,
"route_limit": 256,
"routes_overflowed": false,
"requests_total": 120345,
"status_total": { "1xx": 0, "2xx": 118000, "3xx": 1200, "4xx": 1100, "5xx": 45 },
"window": {
"covered_seconds": 271,
"requests": 1626,
"requests_per_second": 6.0,
"status": { "1xx": 0, "2xx": 1600, "3xx": 10, "4xx": 15, "5xx": 1 },
"latency_ms": { "p50": 2.5, "p90": 10.0, "p95": 25.0, "p99": 100.0, "max": 412.3 }
},
"routes": [
{
"method": "GET",
"route": "/orders/{id}",
"requests_total": 5400,
"status_total": { "1xx": 0, "2xx": 5300, "3xx": 0, "4xx": 98, "5xx": 2 },
"window": { "covered_seconds": 271, "requests": 80, "requests_per_second": 0.295,
"status": { "1xx": 0, "2xx": 79, "3xx": 0, "4xx": 1, "5xx": 0 },
"latency_ms": { "p50": 5.0, "p90": 10.0, "p95": 10.0, "p99": 25.0, "max": 19.8 } }
}
]
},
"database": {
"state": "observed",
"queries_total": 9876,
"n_plus_one": {
"threshold": 3,
"findings_total": 12,
"findings": [
{ "method": "GET", "route": "/orders/{id}", "fingerprint": "LineItem.find (line_items)",
"requests": 12, "max_occurrences": 25, "last_seen": 1792648790 }
]
}
},
"queues": [
{ "name": "default", "pending": { "state": "observed", "count": 3 },
"in_flight": 1, "completed_total": 5000, "failed_total": 4,
"retried_total": 2, "stalled_recovered_total": 0 }
]
}
- Routes are keyed by the matched route pattern (
/orders/{id}) and a normalized method (the standard methods, elseOTHER); requests that matched no route are counted as(unmatched). At most 256 method and pattern pairs are kept; later ones share{"method": "*", "route": "(other)"}androutes_overflowedbecomestrue. - Totals count since the process started. The window covers the last
five minutes in one-minute slots;
covered_secondssays how much of it has elapsed (less right after start-up), andrequests_per_secondisrequests / covered_seconds, rounded to thousandths. - Latency percentiles come from a fixed histogram (bucket bounds from
0.1 ms to 10 s): each value is the upper bound of its bucket, capped at the
window’s maximum, and
latency_msisnullwithout requests in the window. Durations run from the outermost framework layer to the response headers. - Database counts the outermost ORM operations started by requests and
lists repeated operations as the ORM’s static
Model.operation (table)fingerprint, never SQL text or values (at most 64 entries, the least recently seen replaced first). It needs the framework’s tracing subscriber (Serverinstalls it) and aRUST_LOGthat keepsrullst_ormatinfo; otherwise it reports{"state": "unavailable", "reason": "subscriber_not_installed"}or"orm_spans_filtered". - Queues:
pendingis read from the queue store (all workers, with a 250 ms limit per queue;timeoutordriver_errorotherwise, never the driver’s message). The other counters cover this process’s workers since start: running handlers, completed, failed (error, panic, timeout, no handler), retried (requeued at shutdown, handed back for lack of a handler, orQueue::retry_failed_job) and stalled leases recovered.
What it never contains: raw paths, query strings, headers, cookies, client addresses, user identifiers, request or response bodies, SQL text or bound values, error messages, environment variables or configuration values. Route patterns and queue names are chosen by your code, so keep personal data out of them.
Watch your projects from Studio
The standalone Studio runs on your machine, without an application process, and shows the document of each project you add: overview (radar, request rate, error rate, latency), routes, queues and N+1 findings, with a project switcher in the header. It only connects outwards to your projects; nothing new is collected or stored on the servers.
cargo install rullst-studio --version =13.0.0-alpha.4 --features remote --locked
rullst-studio # http://127.0.0.1:5556/studio
# or, with the CLI installed:
cargo rullst studio --remote # --port 7000 for another port
Pin the version: a plain cargo install rullst-studio selects the
end-of-life 12.3.0, which has no remote feature. The remote feature is not
enabled by default: applications that mount
Studio’s local pages in development do not build its HTTP client, encryption
or SQLite history.
It listens on 127.0.0.1 only, with the same protections as Studio’s local
pages: loopback peer and Host, same-origin forms, and a per-process form
token. Its pages are the remote ones only: the data browser, .env viewer,
AI and API playgrounds and migration pages do not exist in this mode.
Add a project
On the Projects page, enter a name, the project’s base URL and one of the
tokens of its RULLST_MONITOR_TOKENS; give each machine its own token, so you
can revoke it alone. Studio reads <base URL>_rullst/monitor/v1.
- The base URL must use
https://, orhttp://to a loopback address such ashttp://127.0.0.1:9000(an SSH tunnel). User information, query strings and fragments are refused. Certificates are always validated. - The token is sent only as
Authorization: Bearerto that origin. Studio follows no redirect (a redirect is shown as a status), ignoresHTTP_PROXY/HTTPS_PROXY, and reads at most 1 MiB within 5 seconds. Anything else (an unreachable host,401,426, an invalid document) is a status on the page, never the remote body. - Every value from a project is HTML-escaped: a hostile or compromised application cannot run script in Studio.
Where your tokens are kept
| File | Location (Linux and macOS / Windows) | Contents |
|---|---|---|
| Project list | $XDG_CONFIG_HOME/rullst/studio/projects.json (default ~/.config/…) / %APPDATA%\rullst\studio\projects.json | Names, base URLs, encrypted tokens |
| Key | $XDG_DATA_HOME/rullst/studio/projects.key (default ~/.local/share/…) / %LOCALAPPDATA%\rullst\studio\projects.key | 32 random bytes |
| History | $XDG_DATA_HOME/rullst/studio/history.sqlite / %LOCALAPPDATA%\rullst\studio\history.sqlite | Aggregates of watched projects and fired alerts |
Both are created owner-only (0600 in 0700 directories on Unix) and never
followed through a symlink; Studio warns when either is readable by other
users. Each token is encrypted with AES-256-GCM and bound to its project name
and base URL: if someone edits the URL in the file, the token no longer
decrypts and is never sent to the new address (remove and add the project
again). A copy of the project list alone (a dotfile repository, a backup of
~/.config) reveals no token. Malware running as your user can still read
both files; the operating system keychain is not used.
History and alerts
On the Alerts page, tick Watch for a project: while Studio runs, it reads that project every 60 seconds and keeps, per poll, only aggregates the document already contains: whether it answered, requests per second, window requests, 5xx rate, p95 latency, and the pending and failed totals of its queues. Routes, queue names and other labels are not stored. The History page charts them for the last hour, day or week. Samples older than seven days, beyond 10,080 per project (a week at one per minute) and alerts beyond the newest 500 are deleted on every write; removing a project deletes its history. Nothing new is collected on the server.
Each project has alert rules, all off by default:
| Rule | Fires when |
|---|---|
| Unreachable | the project cannot be read (any status on the overview page) |
| 5xx rate | the window’s 5xx share reaches the percentage, with at least 20 requests in the window |
| p95 latency | the window’s p95 reaches the milliseconds |
| Pending jobs | the pending jobs of all its queues reach the count |
| Queue failures | this many jobs failed between two polls (a server restart, which resets the counters, never fires it) |
A rule fires again for the same project only after its cool-down (15 minutes by default, 1 to 1,440). Alerts appear on the Alerts page, with a badge until you acknowledge them. Optionally, each alert is posted once to a webhook you configure (Slack, Teams, a chat bot, your own receiver):
{"source": "rullst-studio", "project": "shop", "rule": "p95_latency",
"message": "shop: p95 latency 412.3 ms (threshold 250 ms)", "at": 1792648800}
The webhook URL is a secret like a token: it is encrypted in the project list
and never shown again. It must be https:// without user information. The
post follows no redirect, ignores proxy variables and gives up after five
seconds; the outcome (delivered, failed: HTTP 500, failed: timeout…) is
recorded on the alert, and it is not retried. Alert text is Studio’s own
wording with the project name and numbers, never labels from the remote
document. Polling stops when Studio stops; for alerts around the clock, run
rullst-studio on an always-on machine or keep using your existing uptime
monitoring.
Reaching private servers
Studio does not open tunnels itself. Use the tool you already trust:
- SSH tunnel:
ssh -N -L 9000:127.0.0.1:3000 deploy@server, then addhttp://127.0.0.1:9000. On the server, callMonitor::allow_loopback_plaintext(true)so the application accepts the tunnel’s plain HTTP from loopback in production. - WireGuard or Tailscale: the monitor still requires TLS evidence in
production, so put the application’s reverse proxy (with HTTPS) on the VPN
address, or use
tailscale serveto publish it over HTTPS, and add thathttps://URL. Restrict/_rullst/monitor/to the VPN range in the proxy.
Limits
- Each process reports itself. Behind a load balancer, each replica has its own counters, budgets and lockouts, and a poll reaches one of them; poll replicas individually if you need all of them.
- Counters restart with the process.
- The development hot-reload server does not mount the monitor.
- A panic that aborts the connection is not counted.
- The schema is versioned: fields are only added in a new version, served
next to
v1at a new path.
Overhead
Collection runs on every request once the monitor is mounted: it reads the
matched route, takes a read lock on the route table, updates that route’s
counters under its own lock, and gives the request a scope in which ORM
operations are counted. Measured on an 8-thread Linux machine in a release
build (cargo test --release -p rullst-core --lib monitor::overhead_tests -- --ignored --nocapture --test-threads=1, from a clone of the Rullst
repository): a trivial Axum route took 0.72 µs per request without
the recorder and 1.32 µs with it, so about 0.6 µs per request; the
statistics update alone took about 96 ns, and 70 to 80 ns per call with 4 to 8
threads recording at once. Memory is bounded: about 1 KiB per route pattern
(273 KiB for 257), 64 N+1 entries, and the endpoint’s per-client limiters,
which keep at most 100,000 clients each like Core’s RateLimiter.
💳 Payment Gateways & Financial Infrastructure Guide
Rullst Capital (rullst-capital) provides typed payment, subscription and
webhook contracts with two built-in adapters. Unsupported operations return
typed errors, and mock credentials select deterministic offline behavior.
The adapters share common traits, but they do not all implement every operation. An adapter’s presence is not a promise of geographic availability, tax treatment, settlement time, pricing, or regulatory suitability.
📊 Adapter inventory
| Adapter | Status | Rullst contract |
|---|---|---|
| Stripe | Supported | Owner-bound customer and subscription checkout, bound portal, reconciliation reads, Payment Intents charges, Meter Events, coupons and trials; unsupported methods fail explicitly. |
| InfinitePay | Experimental | Deterministic offline checkout, portal and webhook fixtures. Live plan-only checkout and live callbacks return UnsupportedOperation until a reviewed contract is validated against a live account. |
| Your own adapter | Extension point | Implement BillingProvider (and optionally MeteredBillingProvider) in the application; see Writing your own payment provider. |
v13 removed the Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce, PicPay and Wise (payout) adapters; see the v13 migration guide row “Capital providers and NFS-e removed”. Provider pricing and terms change. Check the provider’s current official documentation and the concrete trait implementation before selecting an adapter.
Failures, timeouts, and retry ownership
Reviewed live methods use a single bounded egress contract: five-second connect and twenty-second whole-request timeouts, no redirects, no ambient proxy variables, and at most one MiB of JSON. Checkout responses additionally require a bounded credential-free HTTPS URL and provider-specific origin/identity checks. Stripe hosted Checkout preserves its documented opaque fragment; other checkout locations must not carry a fragment. These controls do not prove that a provider account, product, price, or operation is accepted live.
CapitalError::Provider exposes only static provider/operation labels, a
ProviderFailureKind, optional HTTP status, bounded numeric Retry-After, and
one of three dispositions: permanent, transient, or rate-limited. It does not
retain a raw URL, credential, response body, or reqwest diagnostic. Log those
structured fields instead of formatting the original request.
Rullst deliberately does not retry mutations. A transient classification means only that a later attempt may succeed. Before retrying, the application must prove that the concrete operation forwards the same persisted idempotency key; otherwise reconcile provider state first. Backoff, jitter, attempt budgets, dead-letter handling, and operator alerts remain explicit application policy.
🔍 Selection model
Choose a provider only after checking which trait methods the Rullst adapter implements, the currencies and countries enabled on the actual merchant account, the current provider contract, webhook replay/idempotency requirements, and the application’s legal and tax responsibilities. Merchant-of-record status and tax handling are external contractual properties, not guarantees made by Rullst.
💻 Rust Code Integration Examples
1. Choose the implemented operation
The Capital README
lists each adapter’s current operations. Stripe has a generated durable
subscription integration (cargo rullst make:billing and the SaaS starter). InfinitePay
is experimental and only its offline fixtures succeed. For another gateway,
write an application-owned adapter as described in
Writing your own payment provider.
2. Create an owner-bound Stripe checkout
#![allow(unused)]
fn main() {
use rullst_capital::{CapitalError, StripeCheckoutRequest, StripeProvider};
async fn checkout() -> Result<(), CapitalError> {
let provider = StripeProvider::new("mock_key", "mock_webhook");
// In the host: authorize this owner and persist the account/mode-scoped
// customer binding and immutable attempt before dispatch.
let attempt = StripeCheckoutRequest::new(
"cus_existing", "price_monthly", "owner_opaque", "attempt_unique",
"https://app.example/billing/success", "https://app.example/billing/cancel",
)?;
let session = provider.create_subscription_checkout(&attempt).await?;
let _ = session; // Persist its ID before a 303 handoff; never infer payment.
Ok(())
}
}
The account’s recurring price allowlist is server-owned. A local user may never
supply another customer’s ID or choose a raw provider URL. For the complete
SQLx/Turso application flow, use the SaaS starter or cargo rullst make:billing
and follow the generated BILLING.md; see the
SaaS billing tutorial.
3. Cryptographically Verified Webhook Endpoint
Signature verification authenticates raw delivery bytes. It does not establish
application ownership or settlement. Use the provider’s typed verified envelope
with the persisted owner/customer/checkout binding; do not grant or revoke access
from WebhookEvent.customer_email, an event name alone or a browser return.
For Stripe, the generated handlers verify signed Checkout/subscription events,
read current provider state under a database revision fence and atomically
commit the scoped event receipt with subscription state. Exact replays do not
repeat changes; conflicting receipts, foreign customers and obsolete attempts
are rejected. Unknown provisioning/checkout outcomes require bounded recovery.
InfinitePay accepts only explicit mock_* webhook fixtures; a real secret
returns UnsupportedOperation until callback authentication is validated. A
custom adapter supplies its own durable orchestration, atomic event processing
and reconciliation.
Rullst also supplies canonical Axum/Actix webhook middleware for supported
normalized events. The production entry points reject empty/mock_* secrets.
A normalized WebhookEvent is a lower-level input, not a complete entitlement
decision; preserve raw verified event identity and scope where needed.
Actix Web adapter
Enable rullst-capital with default-features = false, features = ["actix"],
or enable rullst/capital-actix through the umbrella crate, and add
actix-web as a direct application dependency. An explicit
provider-bound state avoids global provider configuration and makes the replay
boundary visible:
#![allow(unused)]
fn main() {
use actix_web::{App, HttpMessage, HttpRequest, HttpResponse, HttpServer, middleware, web};
use rullst_capital::{
InMemoryWebhookReplayStore, StripeProvider, WebhookEvent,
WebhookMiddlewareState, verify_webhook_actix_with_state,
};
use std::sync::Arc;
async fn handle_billing_event(request: HttpRequest) -> HttpResponse {
let Some(event) = request.extensions().get::<WebhookEvent>().cloned() else {
return HttpResponse::InternalServerError().finish();
};
// This example only demonstrates middleware extraction. Before any domain
// mutation, bind verified identity and reconcile current state as above.
let _ = event;
HttpResponse::NoContent().finish()
}
async fn serve() -> std::io::Result<()> {
let provider = Arc::new(StripeProvider::new(
"sk_live_from_secret_store",
"whsec_from_secret_store",
));
let replay = Arc::new(InMemoryWebhookReplayStore::default());
let state = WebhookMiddlewareState::production_with_provider(provider, replay);
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(state.clone()))
.wrap(middleware::from_fn(verify_webhook_actix_with_state))
.route("/webhooks/capital", web::post().to(handle_billing_event))
})
.bind(("127.0.0.1", 8080))?
.run()
.await
}
}
The default in-memory replay store is atomic only inside one process. Enable
rullst-capital/webhook-sql (or umbrella rullst/capital-webhook-sql) to share
a bounded replay ledger across SQLite, PostgreSQL, MySQL, or MariaDB processes:
#![allow(unused)]
fn main() {
use rullst_capital::{
SqlWebhookReplayStore, StripeProvider, WebhookMiddlewareState,
};
use std::{sync::Arc, time::Duration};
async fn webhook_state(
database_url: String,
) -> Result<WebhookMiddlewareState, rullst_capital::CapitalError> {
let replay = Arc::new(
SqlWebhookReplayStore::connect(
database_url,
100_000,
Duration::from_secs(24 * 60 * 60),
)
.await?,
);
replay.prepare_schema().await?;
let provider = Arc::new(StripeProvider::new(
"sk_live_from_secret_store",
"whsec_from_secret_store",
));
Ok(WebhookMiddlewareState::production_with_provider(
provider, replay,
))
}
}
Run equivalent reviewed DDL through deployment migrations instead of relying on request-time setup. Capacity/TTL are immutable for an existing ledger; drift, corruption, storage failure, and a full unexpired ledger fail closed. Only provider-scoped SHA-256 claims are stored, not raw payloads or event IDs.
SQL-backed middleware claims the payload before handler dispatch. It prevents
cross-process replay but cannot make handler delivery exactly once: a crash can
still occur between admission and a business mutation. For an atomic
relational path, verify the exact provider payload through its low-level
contract, select the provider’s stable event ID, and call
check_and_record_event_key_with_transaction in the same transaction as the
domain mutation. Do not also pre-claim that event through SQL middleware.
External calls, e-mail, and queues still need an outbox, idempotent consumers,
and reconciliation.
4. Provider-Specific Metered Usage
Use MeteredBillingProvider with StripeMeterEvent. The Stripe request
carries customer, configured event name, positive value, bounded timestamp and
an identifier forwarded to the provider and HTTP idempotency header.
Stripe’s provider identifier has only a rolling uniqueness window, so keep a
durable application record of submitted events and make reconciliation
idempotent. A custom adapter whose provider has no deduplication key returns
UsageDeduplication::ApplicationOutboxRequired; claim the event key durably
before sending. Protocol fixtures verify request/response shape and bounds;
they do not replace live provider-account testing.
5. Payment-Bound PDF Invoice Delivery
With the umbrella capital-mail feature, bind an authoritative invoice to the
final charge receipt and prepare a pipeline-validated HTML/PDF message through
rullst::mail::PaidInvoiceDelivery. Non-final/mock receipts and mismatched
recipient, minor-unit total or currency fail before delivery. Persist the
stable delivery key under a unique constraint before calling send; the
bridge is at-least-once and does not infer webhook reconciliation.
The complete runnable shape and its outbox boundary are shown in Tutorial 19.
6. Adding another gateway
Rullst no longer ships adapters for other gateways or payouts. Implement
BillingProvider for the gateway in application code, declare its webhook
verification mode, verify the provider’s exact signed bytes inside
handle_webhook, and mount it behind the same canonical middleware with
WebhookMiddlewareState::production_with_provider. The
custom provider guide walks through a compiling
example. Payouts, transfers and fiscal documents are application or
separate-product responsibilities.
🛡️ Security controls and boundaries
- Bounded verification: webhook handlers should bound the body before parsing and reject a missing or malformed signature. Reading and parsing still allocate according to the concrete HTTP stack and payload.
- Cryptographic verification: supported webhook adapters use HMAC or
constant-time verification for the exact signed bytes. Each provider’s
timestamp/replay policy and deployed secret lifecycle still require review.
The default replay store is process-local; multi-instance deployments need
the opt-in
webhook-sqlledger or another durable shared idempotency boundary. It holds at most 10,000 proofs for 24 hours each and answers 503 when full rather than evict an unexpired proof; size a store forverify_webhook_with_statewhen one process verifies more than about 10,000 deliveries per day. - Typed parsing: supported provider responses map into Rust enums and structs without runtime reflection. A typed response does not establish authorization, idempotency, or correctness of the upstream service.
Writing your own payment provider
Rullst Capital ships two built-in adapters: Stripe (supported) and InfinitePay (experimental until it is validated against a live account). Every other gateway is integrated by the application through the same provider-neutral contracts the built-in adapters use. Version 13 removed the Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce, PicPay and Wise adapters; see the migration guide row “Capital providers and NFS-e removed”.
This guide walks through a complete adapter for a fictitious gateway,
AcmePay. The code below is included from
rullst-capital/examples/custom_provider.rs,
which CI compiles and which runs offline from a clone of the Rullst repository:
cargo run -p rullst-capital --example custom_provider
In an application, the same types are available through the facade as
rullst::capital::* with the capital feature. BillingProvider is an
#[async_trait] trait: use the async-trait crate, as the example does, or
the facade’s rullst::async_trait. The example’s HMAC check uses ring, hex
and subtle; add equivalent crates to your own Cargo.toml.
What the framework does and what the adapter does
| Responsibility | Owner |
|---|---|
Bounding the webhook body (2 MiB), rejecting duplicate Standard Webhooks headers, refusing mock_* secrets in production state, replay protection, restoring the exact body and inserting the WebhookEvent | verify_webhook_with_state (Axum) or verify_webhook_actix_with_state (Actix) |
| Declaring real or mock verification, authenticating the exact signed bytes, checking freshness, mapping only documented states | Your BillingProvider implementation |
| Owner binding, plan allowlists, persistence, reconciliation and entitlements | Your application, with the Capital checkout, entitlement and quota contracts |
The adapter is trusted server code. A common trait does not mean every gateway
supports every operation: an operation the gateway does not offer, or that you
have not reviewed, must return CapitalError::UnsupportedOperation instead of
fabricating success.
1. Implement BillingProvider
/// Largest clock difference accepted between AcmePay and this server.
const WEBHOOK_TOLERANCE_SECONDS: i64 = 5 * 60;
/// Adapter for the fictitious AcmePay gateway.
pub struct AcmePayProvider {
api_key: String,
webhook_secret: String,
}
impl AcmePayProvider {
pub fn new(api_key: impl Into<String>, webhook_secret: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
webhook_secret: webhook_secret.into(),
}
}
/// Empty or `mock_*` credentials select the deterministic offline fixture.
fn is_offline(&self) -> bool {
self.api_key.is_empty() || self.api_key.starts_with("mock_")
}
/// Checks `acme-signature: t=<unix>,v1=<hex HMAC-SHA256("<t>.<body>")>`.
fn verify_signature(
&self,
payload: &[u8],
headers: &HashMap<String, String>,
now: i64,
) -> Result<(), CapitalError> {
let header = headers
.get("acme-signature")
.ok_or_else(|| CapitalError::InvalidSignature("missing acme-signature".into()))?;
if self.webhook_verification_mode()? == WebhookVerificationMode::Mock {
// Local fixtures still require the configured `mock_*` secret.
return match header
.as_bytes()
.ct_eq(self.webhook_secret.as_bytes())
.into()
{
true => Ok(()),
false => Err(CapitalError::InvalidSignature("mock signature".into())),
};
}
let mut timestamp = None;
let mut signature = None;
for part in header.split(',') {
match part.split_once('=') {
Some(("t", value)) => timestamp = value.parse::<i64>().ok(),
Some(("v1", value)) => signature = hex::decode(value).ok(),
_ => {}
}
}
let (Some(timestamp), Some(signature)) = (timestamp, signature) else {
return Err(CapitalError::InvalidSignature(
"malformed acme-signature".into(),
));
};
if (now - timestamp).abs() > WEBHOOK_TOLERANCE_SECONDS {
return Err(CapitalError::StaleWebhook("acmepay timestamp".into()));
}
let key = hmac::Key::new(hmac::HMAC_SHA256, self.webhook_secret.as_bytes());
let mut signed = format!("{timestamp}.").into_bytes();
signed.extend_from_slice(payload);
// `ring::hmac::verify` compares in constant time.
hmac::verify(&key, &signed, &signature)
.map_err(|_| CapitalError::InvalidSignature("acmepay signature".into()))
}
}
#[async_trait]
impl BillingProvider for AcmePayProvider {
fn name(&self) -> &'static str {
"acmepay"
}
// Without this override the canonical middleware refuses the provider.
fn webhook_verification_mode(&self) -> Result<WebhookVerificationMode, CapitalError> {
match self.webhook_secret.trim() {
"" => Err(CapitalError::ConfigurationError(
"acmepay webhook secret cannot be empty".into(),
)),
secret if secret.starts_with("mock_") => Ok(WebhookVerificationMode::Mock),
_ => Ok(WebhookVerificationMode::Real),
}
}
async fn create_checkout_session(
&self,
_customer_email: &str,
plan_id: &str,
_redirect_url: &str,
) -> Result<String, CapitalError> {
if plan_id.is_empty()
|| !plan_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Err(CapitalError::ConfigurationError("invalid plan ID".into()));
}
if self.is_offline() {
// A reserved `.invalid` host never reaches a real gateway.
return Ok(format!("https://mock.acmepay.invalid/checkout/{plan_id}"));
}
// Call the gateway here, bind the response to the request and return
// only a validated HTTPS URL. Never report success you did not verify.
Err(CapitalError::UnsupportedOperation(
"acmepay live checkout is not implemented in this example".into(),
))
}
fn handle_webhook(
&self,
payload: &[u8],
headers: &HashMap<String, String>,
) -> Result<WebhookEvent, CapitalError> {
// Authenticate the exact bytes before parsing anything.
self.verify_signature(payload, headers, chrono::Utc::now().timestamp())?;
let json: serde_json::Value = serde_json::from_slice(payload)
.map_err(|_| CapitalError::PayloadParseError("acmepay JSON".into()))?;
let text = |field: &str| {
json["data"][field]
.as_str()
.filter(|value| !value.is_empty())
.map(str::to_owned)
.ok_or_else(|| CapitalError::PayloadParseError(format!("acmepay {field}")))
};
// Map only documented states; an unknown state is an error, not access.
let status = match text("status")?.as_str() {
"active" => SubscriptionStatus::Active,
"trialing" => SubscriptionStatus::Trialing,
"past_due" => SubscriptionStatus::PastDue,
"canceled" => SubscriptionStatus::Canceled,
_ => return Err(CapitalError::PayloadParseError("acmepay status".into())),
};
Ok(WebhookEvent {
subscription_id: text("subscription")?,
customer_id: text("customer")?,
customer_email: text("email")?,
plan_id: text("plan")?,
status,
ends_at: json["data"]["current_period_end"].as_i64(),
})
}
async fn create_customer_portal(
&self,
_customer_email: &str,
_return_url: &str,
) -> Result<String, CapitalError> {
if self.is_offline() {
return Ok("https://mock.acmepay.invalid/portal".into());
}
Err(CapitalError::UnsupportedOperation("acmepay portal".into()))
}
async fn cancel_subscription(&self, subscription_id: &str) -> Result<(), CapitalError> {
if subscription_id.is_empty() {
return Err(CapitalError::SubscriptionError(
"empty subscription ID".into(),
));
}
if self.is_offline() {
return Ok(());
}
Err(CapitalError::UnsupportedOperation("acmepay cancel".into()))
}
// Operations the gateway does not offer fail explicitly.
async fn pause_subscription(&self, _subscription_id: &str) -> Result<(), CapitalError> {
Err(CapitalError::UnsupportedOperation("acmepay pause".into()))
}
async fn report_usage(&self, _: &str, _: &str, _: u64) -> Result<(), CapitalError> {
Err(CapitalError::UnsupportedOperation("acmepay usage".into()))
}
async fn apply_coupon(&self, _: &str, _: &str) -> Result<(), CapitalError> {
Err(CapitalError::UnsupportedOperation("acmepay coupons".into()))
}
async fn extend_trial(&self, _: &str, _: i64) -> Result<(), CapitalError> {
Err(CapitalError::UnsupportedOperation("acmepay trials".into()))
}
}
Points that matter for security and testing:
- Declare the verification mode. The default
webhook_verification_modereturns a configuration error, so an adapter that forgets it can never be mounted behind the middleware. An empty secret is a configuration error; amock_*secret selectsWebhookVerificationMode::Mock. - Authenticate before parsing.
handle_webhookverifies the exact body bytes with the gateway’s documented algorithm and a constant-time comparison (ring::hmac::verifyhere), and checks the timestamp window when the protocol signs one. Mock fixtures still require the configuredmock_*secret. - Map only documented states. An unknown status or a missing identity is a
PayloadParseError, neverActive. - Offline fallback. Empty or
mock_*API keys return deterministic URLs on reserved.invalidhosts, so tests and local sandboxes never contact the gateway. Live paths must bind the provider response to the request before returning it. - Optional contracts. Override
BillingProvider::chargeonly after forwarding the request’s idempotency key and binding amount and currency in the response. Metered billing uses the separateMeteredBillingProvidertrait; returnUsageDeduplication::ApplicationOutboxRequiredfromUsageReceipt::from_verified_provider_responsewhen the gateway offers no deduplication key.
2. Plug it into checkout and subscriptions
/// The subscription owner, loaded from authenticated application state.
struct Workspace {
billing_email: String,
subscription_id: Option<String>,
}
#[async_trait]
impl Billable for Workspace {
fn email(&self) -> String {
self.billing_email.clone()
}
fn subscription_id(&self) -> Option<String> {
self.subscription_id.clone()
}
}
let provider = Arc::new(AcmePayProvider::new("mock_api_key", "mock_webhook_secret"));
let workspace = Workspace {
billing_email: "[email protected]".into(),
subscription_id: Some("sub_42".into()),
};
// Checkout through the trait object, exactly as for a built-in adapter.
let checkout = provider
.create_checkout_session(
&workspace.email(),
"plan_pro",
"https://app.example/billing",
)
.await?;
assert_eq!(checkout, "https://mock.acmepay.invalid/checkout/plan_pro");
// Subscription operations with static dispatch on the explicit provider.
workspace
.subscription_with(provider.as_ref())?
.cancel()
.await?;
let paused = workspace
.subscription_with(provider.as_ref())?
.pause()
.await;
assert!(matches!(paused, Err(CapitalError::UnsupportedOperation(_))));
// `charge_with` keeps the trait's fail-closed default for direct charges.
let charge = workspace
.charge_with(provider.as_ref(), 4_990, "BRL", "cus_1", "pm_1", "order_1")
.await;
assert!(matches!(charge, Err(CapitalError::UnsupportedOperation(_))));
subscription_with and charge_with dispatch statically to the provider you
pass. The compatibility helpers (Billable::subscribe,
Billable::billing_portal_url, Billable::cancel_subscription) use the global
provider; select it once at start-up with
rullst::capital::try_init_provider(Box::new(provider)), which reports a
second initialization instead of replacing the first provider.
Derive the subscription ID and the plan from authenticated application state,
and keep a server-owned plan allowlist. A checkout redirect is not proof of
payment; grant access from verified, reconciled state, for example with the
entitlements::EntitlementGate.
3. Verify webhooks with the canonical middleware
async fn billing_webhook(Extension(event): Extension<WebhookEvent>) -> StatusCode {
// Only events that passed the provider check and replay claim arrive here.
// Persist them against the owner you already know; never trust the email alone.
println!(
"verified {} event for {}",
event.status.as_str(),
event.subscription_id
);
StatusCode::OK
}
fn billing_router(provider: Arc<AcmePayProvider>) -> Router {
let replay_store = Arc::new(InMemoryWebhookReplayStore::default());
// Production uses `WebhookMiddlewareState::production_with_provider`,
// which rejects `mock_*` secrets; this offline example opts in to mocks.
let state = WebhookMiddlewareState::local_mock_with_provider(provider, replay_store);
Router::new()
.route("/billing/webhook", post(billing_webhook))
.layer(middleware::from_fn_with_state(
state,
verify_webhook_with_state,
))
}
Mount WebhookMiddlewareState::production_with_provider in production. It
rejects mock_* secrets with MockWebhookNotAllowed; local_mock_with_provider
is for local fixtures only. The middleware maps failures to HTTP status codes:
401 for a bad signature or stale timestamp, 409 for a replay, 413 for an
oversized body and 503 when the replay store is full or unavailable.
The in-memory replay store is process-local. With several processes, use the
webhook-sql feature: SqlWebhookReplayStore shares replay claims, and its
check_and_record_event_key_with_transaction claims the gateway’s stable event
ID inside your own database transaction. Body-hash replay protection
does not stop a gateway from resending the same event with a different body;
prefer the stable event ID when the gateway provides one.
4. Test the adapter
The example’s main exercises the contract offline:
- a signed mock delivery returns 200 and its identical replay returns 409;
- a wrong signature returns 401 and never reaches the handler;
- the live HMAC path accepts a fresh signature and rejects a stale timestamp;
- unsupported operations and direct charges fail with
UnsupportedOperation.
Add the gateway’s published webhook examples as fixtures, and validate the live adapter against a sandbox account before enabling it in production. Tests with mocks establish local contracts, not provider interoperability.
Writing your own mail transport
Rullst Mail ships four delivery transports: Resend, AWS SES,
SendPulse and SMTP, plus the log, memory and offline mock_*
drivers for development and tests. Every other provider is integrated by the
application through the same MailDriver trait the built-in transports
implement. Version 13 removed the SendGrid, Postmark, Mailjet, Mailtrap and
Azure Communication Services transports; see the
migration guide
row “Mail providers removed”.
This guide walks through a complete transport for a fictitious HTTP email API,
AcmeMail. The code below is included from
rullst-mail/examples/custom_transport.rs,
which CI compiles and which runs offline from a clone of the Rullst repository:
cargo run -p rullst-mail --example custom_transport
In an application, the same types are available through the facade as
rullst::mail::* with the mail feature. The example also uses reqwest,
secrecy, serde_json, chrono and async-trait (MailDriver is an
#[async_trait] trait; the facade’s rullst::async_trait works too); add
equivalent crates to your own Cargo.toml.
What the framework does and what the transport does
| Responsibility | Owner |
|---|---|
| Header-injection, recipient, link and homograph checks, outbound secret redaction, and attachment count, size and CID bounds | DeliveryPipeline::prepare, which the transport calls first |
| Default sender, queueing and scheduling, tenant routing, failover, suppression, attachment inspection and observations | The Mail facade, TenantMailResolver, FailoverDriver, SuppressionGuard, AttachmentInspectionGuard and ObservedMailDriver, which wrap any MailDriver |
| Authenticating to the provider, mapping the message to its API, rejecting what the API cannot express, bounding and checking the receipt, and classifying failures | Your MailDriver implementation |
| Sender-domain verification, provider limits, bounce and complaint handling, retention and the provider contract | Your application and provider account |
The transport is trusted server code. A transport that cannot express part of a message (a schedule, an inline image, an unsubscribe header) must return an error instead of sending the message without it.
1. Implement MailDriver
/// Stable low-cardinality label used in errors and telemetry.
const PROVIDER: &str = "acmemail";
/// Largest delivery receipt read from AcmeMail.
const MAX_RECEIPT_BYTES: usize = 16 * 1024;
/// Messages the offline fixture captured instead of sending.
pub type OfflineOutbox = Arc<Mutex<Vec<Message>>>;
/// Transport for the fictitious AcmeMail HTTP API.
pub struct AcmeMailDriver {
api_key: SecretString,
endpoint: String,
client: reqwest::Client,
outbox: OfflineOutbox,
}
impl AcmeMailDriver {
/// Creates the transport. Empty or `mock_*` keys select the offline fixture.
pub fn try_new(api_key: impl Into<String>) -> Result<Self, MailError> {
let api_key = api_key.into();
if api_key.contains(['\r', '\n']) || api_key.len() > 4096 {
return Err(MailError::ConfigError(
"AcmeMail API key is malformed".into(),
));
}
// No redirects (they would forward the key and the message), and
// deadlines for connecting and for the whole exchange.
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.build()
.map_err(|_| MailError::ConfigError("cannot build the AcmeMail client".into()))?;
Ok(Self {
api_key: SecretString::from(api_key),
endpoint: "https://api.acmemail.invalid/v1/messages".into(),
client,
outbox: OfflineOutbox::default(),
})
}
/// Uses another endpoint: HTTPS, or plain HTTP to a loopback test fixture.
pub fn try_with_endpoint(mut self, endpoint: impl Into<String>) -> Result<Self, MailError> {
let endpoint = endpoint.into();
let loopback = endpoint.starts_with("http://127.0.0.1:");
if !endpoint.starts_with("https://") && !loopback {
return Err(MailError::ConfigError(
"AcmeMail endpoint must use HTTPS".into(),
));
}
self.endpoint = endpoint;
Ok(self)
}
/// Whether the key selects the real API or the offline fixture.
pub fn delivery_mode(&self) -> DeliveryMode {
credential_mode(self.api_key.expose_secret())
}
/// The offline fixture's captured messages, shared with every clone.
pub fn offline_outbox(&self) -> OfflineOutbox {
Arc::clone(&self.outbox)
}
/// The API request body. Content the API cannot express is an error,
/// never silently dropped.
fn payload(message: &Message) -> Result<Value, MailError> {
let from = message.from.as_deref().ok_or_else(|| {
MailError::ConfigError(
"AcmeMail requires a verified sender: set `from`, or MAIL_FROM for the Mail facade"
.into(),
)
})?;
if message
.send_at
.is_some_and(|send_at| send_at > chrono::Utc::now())
{
return Err(MailError::ConfigError(
"AcmeMail cannot schedule mail; use Mail::enqueue with a durable queue".into(),
));
}
if message.attachments.iter().any(|item| item.is_inline()) {
return Err(MailError::ValidationError(
"AcmeMail has no inline images; use a transport that supports CIDs".into(),
));
}
let mut headers = serde_json::Map::new();
if let Some(value) = message.list_unsubscribe_header() {
headers.insert("List-Unsubscribe".into(), Value::String(value));
// RFC 8058 one-click unsubscription requires an HTTPS URI.
if message
.unsubscribe_url
.as_deref()
.is_some_and(|url| url.starts_with("https://"))
{
headers.insert(
"List-Unsubscribe-Post".into(),
Value::String("List-Unsubscribe=One-Click".into()),
);
}
}
let attachments: Vec<Value> = message
.attachments
.iter()
.map(|item| {
json!({
"filename": item.filename,
"content_type": item.mime_type,
"content": item.to_base64(),
})
})
.collect();
Ok(json!({
"from": from,
"to": message.to,
"subject": message.subject,
"text": message.body_text,
"html": message.body_html,
"headers": headers,
"attachments": attachments,
}))
}
/// Reads at most `MAX_RECEIPT_BYTES` and requires a message ID.
async fn receipt(mut response: reqwest::Response) -> Result<(), MailError> {
let invalid = || MailError::SendError("AcmeMail returned an invalid receipt".into());
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|_| MailError::transport(PROVIDER, "receipt read failed"))?
{
if body.len() + chunk.len() > MAX_RECEIPT_BYTES {
return Err(invalid());
}
body.extend_from_slice(&chunk);
}
let receipt: Value = serde_json::from_slice(&body).map_err(|_| invalid())?;
match receipt.get("id").and_then(Value::as_str) {
Some(id) if !id.is_empty() && id.len() <= 128 => Ok(()),
_ => Err(invalid()),
}
}
}
#[async_trait]
impl MailDriver for AcmeMailDriver {
async fn send(&self, message: &Message) -> Result<(), MailError> {
// The shared pre-flight checks: header injection, recipients,
// outbound secrets, links and attachment bounds.
let prepared = DeliveryPipeline::prepare(message)?;
let message = prepared.message();
let body = Self::payload(message)?;
if self.delivery_mode() == DeliveryMode::OfflineMock {
self.outbox
.lock()
.map_err(|_| MailError::DriverError("AcmeMail outbox is unavailable".into()))?
.push(message.clone());
return Ok(());
}
let response = self
.client
.post(&self.endpoint)
.bearer_auth(self.api_key.expose_secret())
.json(&body)
.send()
.await
.map_err(|_| MailError::transport(PROVIDER, "request failed before a response"))?;
if response.status().is_success() {
return Self::receipt(response).await;
}
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.map(|seconds| Duration::from_secs(seconds.min(86_400)));
// Error bodies can echo addresses and content: keep only the status
// and the retry delay. 429 becomes `RateLimited`, 5xx is transient.
Err(MailError::from_provider_response(
PROVIDER,
response.status().as_u16(),
"provider response body omitted",
retry_after,
))
}
}
Points that matter for security and testing:
- Run the pipeline first.
DeliveryPipeline::prepareapplies the same pre-flight checks as the built-in transports; sendprepared.message(), never the original. - Never invent a sender. A message without
fromfails withMailError::ConfigErrorbefore any request. TheMailfacade fills it fromMAIL_FROMor[mail] frombefore the transport sees it. - Reject what the API cannot express. A future
send_atwithout provider scheduling, inline CID images or headers the API has no field for are errors, not silent drops. Durable scheduling belongs toMail::enqueue. - Offline fallback. An empty or
mock_*key selectsDeliveryMode::OfflineMockthroughcredential_mode, and the transport captures the message without network I/O, as section 3.5 of Rullst’sAGENTS.mdrequires. Validate the payload before that check so the fixture enforces the live contract. - Bound the HTTP exchange. Disable redirects (they would forward the key and the message), set connect and request deadlines, require HTTPS except for a loopback test fixture and read at most a bounded receipt.
- Classify failures.
MailError::from_provider_responseturns HTTP 429 intoRateLimitedwith itsRetry-Afterand 5xx into a transient error;MailError::transportmarks a failure before any response.FailoverDrivermoves only transient and rate-limited failures to a fallback. Keep provider error bodies out of the error: they can echo addresses and content. - Accept only a real receipt. Success requires the provider’s message ID;
a 2xx without one is a
SendError. Acceptance is not proof of inbox delivery. - Idempotency.
send_with_delivery_iddefaults to at-least-once. Override it only when the provider deduplicates by a key you forward.
2. Install it
// 4. Install the transport for the `Mail` facade (and its queue worker)...
let transport = AcmeMailDriver::try_new("mock_acme")?;
let facade_outbox = transport.offline_outbox();
Mail::set_driver(Box::new(transport));
Mail::send_now(welcome("[email protected]")).await?;
Mail::reset_driver();
// ...or for one tenant, with the global default for everyone else.
let (default_driver, default_store) = MemoryDriver::isolated();
let resolver = TenantMailResolver::with_default(default_driver);
resolver.register("tenant_acme", AcmeMailDriver::try_new("mock_tenant")?)?;
resolver
.send_for_tenant("tenant_acme", &welcome("[email protected]"))
.await?;
resolver
.send_for_tenant("tenant_other", &welcome("[email protected]"))
.await?;
// Shared guards wrap a custom transport like a built-in one.
let store = InMemorySuppressionStore::new(16, 16)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
store
.record(SuppressionEvent::try_new(
PROVIDER,
"evt_bounce_1",
"[email protected]",
SuppressionReason::HardBounce,
now,
)?)
.await?;
let guarded = SuppressionGuard::new(AcmeMailDriver::try_new("mock_acme")?, store);
assert!(matches!(
guarded.send(&welcome("[email protected]")).await,
Err(MailError::SuppressedRecipient { .. })
));
Mail::set_driver replaces the facade’s configured driver for every send and
for the queue worker (register_mail_handler) in that process, so install it
at start-up in every process that sends or works the mail queue.
Mail::reset_driver returns to MAIL_DRIVER. A TenantMailResolver selects a
transport per tenant, with a default for the others. Suppression, attachment
inspection and observation wrap the transport with static dispatch, as they
wrap the built-in ones.
A MAIL_DRIVER value cannot name a custom transport: the facade builds only
the kept drivers from configuration, and a removed driver name fails with
MailError::ConfigError instead of falling back.
3. Test the transport
The example’s main exercises the contract offline:
- a
mock_*key captures the message and never reaches the network; - a message without a sender, and one with an inline image, fail before any request;
- against a loopback HTTP fixture, a 202 receipt succeeds, a 429 becomes
RateLimitedwith a seven-secondRetry-After, and a 503 fails over to the fallback driver; - the facade, a tenant resolver and a suppression guard use the transport.
Add the provider’s documented request and response examples as fixtures, and validate the live transport against a sandbox account and a verified sender before enabling it in production. Tests with mocks establish local contracts, not provider interoperability.
Examples and generated starters
The repository examples and CLI starters serve different purposes:
| Artifact | Purpose | Trust boundary |
|---|---|---|
examples/blog | Workspace integration showcase with local data, interactive demos, and offline provider fixtures. | Development-only; not a production template or compliance proof. |
examples/saas | Reproducible CLI-generated SQLite SaaS with authenticated notes, tenant membership and the same HTTP journey used in CI. | Disposable Linux development example; membership is an explicit local operator action. |
examples/webgpu | Browser wave-interference lesson served by rullst-core, with WebGPU rendering and a Canvas fallback. | Public read-only demo on loopback; browser output establishes no server-owned result. |
examples/lms, examples/erp, examples/portfolio | Complete applications that were the LMS, ERP and Portfolio starters of cargo rullst new until 13.0.0-alpha.4, kept as workspace members with their own tests. | Examples to copy from, not supported starters: no generator or upgrade path behind them. |
| CLI starters (Blank, API, SaaS) | Small starting structures generated into a new project. | Generated output must be reviewed, configured, formatted, checked, and tested by the application owner. |
No example is expected to exercise 100% of workspace behavior. External provider paths use deterministic mock credentials so CI and local development do not make live purchases, send email, or call cloud LLMs.
Blog showcase
The blog package demonstrates:
- server-rendered HTML and Active Record persistence;
- a parameterized repository query;
- a LiveView/WebSocket presentation example (Wasm islands are covered by
cargo rullst make:islandand the islands tutorial, not by this showcase); - Pico CSS and a small embedded file-template fixture (
include_str!plus fixed placeholder replacement; not Tera or another template engine); Billablequota evaluation and offline Stripe/InfinitePay adapter fixtures;- bounded security-helper demonstrations and a local AI/vector fixture;
- pages that render under the unrelaxed production Content Security Policy, with same-origin stylesheets, scripts and vendored HTMX/Pico.css files;
- a debug-only standalone Studio and Nexus access that is loopback-only in debug builds and credential-protected in release builds.
The complete, current route list is maintained in
examples/blog/README.md alongside its configuration requirements.
Small SaaS example
The SaaS example installs the source CLI, generates the starter and adds a small notes API using database-owned membership, parameterized SQL and owner authorization. Its interactive mode opens a disposable loopback application:
python3 examples/saas/run.py --serve
Follow examples/saas/README.md to register a user, grant local membership and
call the notes API. Without --serve, the same command runs its automated
authentication, isolation, CSRF, persistence/restart and revocation journey.
The script requires Linux, Python 3.11+, the pinned toolchain and 32 GiB of
initial disk headroom. It removes generated data on exit and uses no real
provider account. This directory holds the canonical example recipe and
resource; it is not a duplicate checked-in copy of a generated application.
LMS, ERP and Portfolio examples
Until 13.0.0-alpha.4, cargo rullst new --blueprint lms|erp|portfolio
generated these applications; the CLI now offers only the Blank, API and SaaS
starters, and --blueprint lms, erp, portfolio or blog fails before
writing anything with a report that names the example. Each was generated with
that CLI (SQLite, --default), then adapted to the workspace: publish = false,
workspace dependencies, a library with router() plus a thin binary, and the
generated tests (security baseline, CSRF, rate limits, ownership checks) as the
package’s own tests.
| Example | What it shows |
|---|---|
examples/lms | Course catalog, modules and lessons, an accessible player, enrollment, owner-bound progress, sign-in with password reset and a Nexus admin for signed-in administrators. Its owner-boundary test is the threat-model evidence for ACADEMY-02. |
examples/erp | Products, stock movements and orders; the dashboard, forms and Nexus share one administrator policy. |
examples/portfolio | A developer portfolio read from the database and edited in Nexus, checked in a mobile Chromium viewport in Linux CI. |
Run one from its directory, so the server finds .env, Rullst.toml and its
static files:
cd examples/lms
cp .env.example .env # then set APP_KEY to 32 random characters, e.g. `openssl rand -hex 16`
cargo run -p rullst-lms-example -- db:migrate
cargo run -p rullst-lms-example
The ERP and Portfolio examples work the same way with rullst-erp-example and
rullst-portfolio-example.
To start your own application from one, copy the directory and replace its
workspace = true dependencies with the published crates (each README shows
the line). Applications generated earlier from these blueprints keep working:
their code is theirs.
Browser graphics example
The WebGPU example serves embedded HTML, CSS and JavaScript modules behind Rullst’s production CSRF/WAF/secure-header baseline. From the repository root:
cargo run --locked -p rullst-webgpu-example
Then open http://127.0.0.1:3007/webgpu/. All computation stays in the browser;
missing WebGPU support selects the Canvas fallback. Follow
examples/webgpu/README.md for the reusable modules and browser tests.
Blog tenant selection
The blog example inserts a static test-only TenantMembership before the tenant layer.
The X-Tenant-ID header can select only one of those fixed memberships. This
models the separation between an untrusted selector and trusted authenticated
claims. Handlers read the membership-checked TenantContext that TenantLayer
inserts. The Post model declares tenant_column, so its generated queries fail
closed outside with_tenant(...); the hand-written repository SQL binds the same
tenant itself.
In a real application, authentication middleware must derive membership from a verified session or token. Never construct membership from the same client header used to select a tenant.
Provider fixtures
The /pricing page uses mock_* credentials and performs no live checkout. It
lists only the built-in Stripe and InfinitePay (experimental) adapters.
Mock URLs and sample provider metadata are test fixtures, not a promise of live capability, pricing, tax treatment, or regional availability.
Running locally
cd examples/blog
cargo run -p rullst-blog-example
Before local startup, configure APP_KEY in examples/blog/.env as documented
in the example README. The showcase selects its database like Server and the
Artisan commands (DATABASE_URL, .env, then Rullst.toml), so DATABASE_URL
is optional locally and honored in deployments. The debug build needs no Nexus password, but verifies the socket
peer as loopback. A release build does not start Studio and refuses to construct
Nexus without validated NEXUS_ADMIN_USERNAME and NEXUS_ADMIN_PASSWORD
values. Studio is local developer tooling; keep it on a trusted interface.
Verifying examples and starters
For the checked-in examples, run package tests and the workspace trifecta. For CLI output, use a temporary directory and verify every generated project:
cargo fmt --all -- --check
cargo check --all-features
cargo test --all-features
CI smoke or DAST workflows cover only the routes and assertions present in those files. Their logs, commit digest, toolchain, and skipped steps are the evidence; the example itself is not evidence of production readiness.
Axum and SQLx interoperability guide
Rullst builds on Axum, Tokio, Tower and SQLx and preserves direct access to their APIs. This reduces coupling for HTTP and database code; it does not make every optional framework subsystem free to remove.
Mount an existing Axum router
rullst::Router supports conversion to and from axum::Router:
#![allow(unused)]
fn main() {
use axum::{Router as AxumRouter, routing::get};
use rullst::Router;
async fn existing_handler() -> &'static str {
"existing Axum route"
}
let existing = AxumRouter::new().route("/existing", get(existing_handler));
let rullst_router: Router = existing.into();
let axum_router: AxumRouter = rullst_router.into();
let _ = axum_router;
}
The standard Axum extractor and response types remain available, including
through Rullst’s documented re-exports. Generated projects do not depend on
axum directly: add axum = "0.8" (the version Rullst uses) to run this
example as written, or import the same types through rullst::web::axum.
Keep raw SQLx where it is useful
Application-owned SQL can live beside generated ORM queries. Bind every dynamic value and review structural SQL separately:
#![allow(unused)]
fn main() {
use sqlx::PgPool;
async fn active_names(pool: &PgPool) -> Result<Vec<String>, sqlx::Error> {
sqlx::query_scalar("SELECT name FROM users WHERE active = $1")
.bind(true)
.fetch_all(pool)
.await
}
}
Generate an escape-hatch template
cargo rullst eject
The command writes a reviewable Axum/Tokio entry-point template to
src/ejected_main.rs (--output src/<file>.rs chooses another file below
src/) and leaves src/main.rs unchanged. The template is a placeholder route
behind the configured Rullst security baseline, compiled through the
rullst::web::axum re-export. It does not copy the application’s routes;
ORM models, migrations, authentication policy, Studio/Nexus integration and
other selected subsystems can still require deliberate migration work.
cargo rullst eject --force writes the template over src/main.rs instead and
keeps the original at src/main.rs.rullst-backup (it refuses to run while that
backup exists). Use it only with a committed or backed-up worktree, move the
module declarations and routes from the backup into application_routes(),
then run cargo check.
Practical migration sequence
- Keep domain types and handlers independent of framework globals.
- Convert router boundaries incrementally.
- Replace generated ORM calls with raw SQLx only where that trade-off helps.
- Inventory authentication, middleware order, jobs, cache and admin surfaces.
- Run application-specific integration and authorization tests before removing dependencies.
Interoperability is a maintained design goal. “Zero lock-in” or zero migration cost is not a framework guarantee.
GitHub CLI installation and safe login
The GitHub CLI (gh) lets a maintainer inspect workflow runs, pull requests,
issues, Dependabot state, and Code Scanning alerts from a terminal. It does not
gain access merely by being installed: access begins only after the maintainer
completes GitHub’s browser authorization flow.
Official references:
Current Rullst workstation
On the maintainer’s Linux workstation, gh is installed for the current user
at ~/.local/bin/gh. The downloaded GitHub release archive was
verified against its official SHA-256 checksum. Confirm that the command is on
the shell path:
command -v gh
gh --version
If the first command prints nothing, start a new terminal. For the current terminal only, this adds the user-local directory without changing system files:
export PATH="${PATH}:$HOME/.local/bin"
For another machine, use GitHub’s current official installation instructions. Common package-manager entry points are:
# macOS with Homebrew
brew install gh
# Windows with WinGet
winget install --id GitHub.cli
Linux repository commands vary by distribution and can change; copy them from the official Linux installation guide instead of an old blog post.
Browser login for Rullst maintenance
Run this command yourself in the terminal:
gh auth login --hostname github.com --git-protocol https --web --scopes security_events
GitHub will show a one-time code and open its authorization page. Verify that
the browser is on github.com, sign in as the intended Rullst maintainer, read
the requested permissions, and approve only if they match the task. The
additional security_events scope allows the CLI to query Code Scanning data;
GitHub CLI’s web flow also maintains its documented baseline scopes.
Do not paste an access token into chat, a repository file, shell history, an
issue, or a commit. Do not use --insecure-storage. The browser flow asks the
system credential store to keep the credential; if gh reports that no secure
credential store is available, stop and configure one before continuing.
Verify the active account without printing its token:
gh auth status --hostname github.com --active
Never add --show-token to a command whose output may be shared. Once the
status is healthy, a read-only check of Rullst’s open Code Scanning alerts is:
gh api 'repos/Rullst/Rullst/code-scanning/alerts?state=open&per_page=100' \
--jq '.[] | {number, rule: .rule.id, severity: .rule.security_severity_level, url: .html_url}'
Authentication makes inspection possible; it does not authorize dismissing an alert, merging a pull request, changing repository settings, publishing a release, or modifying secrets. Those actions still require an explicit task and an evidence-based review.
Logout and revocation
Remove the local GitHub CLI session with:
gh auth logout --hostname github.com
The logout command removes the locally stored authentication entry but does not revoke the OAuth grant. To revoke it, open GitHub authorized applications, select GitHub CLI, review the impact on other machines, and choose Revoke Access.
After logout, confirm that this workstation no longer has an active session:
gh auth status --hostname github.com
Preparing an application for v13
The current source is 13.0.0-alpha.4, a prerelease, not a stable v13
release. The first prerelease, 13.0.0-alpha.1, was published on October 11, 2026.
The 12.x line, on the frozen v12 branch, reached end of life on 16 October
2026 with 12.3.0 as its last release; main develops v13. The development packages use the same v13
version and internal requirements. rullst-privacy, rullst-supervision,
rullst-media and rullst-labs join the candidate package inventory and were
first published in 13.0.0-alpha.1. Privacy
has explicit privacy-* umbrella features; Supervision, Media and Labs remain
standalone opt-ins.
All twenty packages are published together as each v13 prerelease; the current
one is 13.0.0-alpha.4. Cargo never selects
a prerelease on its own, so cargo add rullst still selects the end-of-life
12.3.0: opt in with
an exact requirement such as rullst = "=13.0.0-alpha.4". A plain
"13.0.0-alpha.4" is a caret requirement that also accepts later alphas, which
may break the API. From 13.0.0-alpha.2 the CLI writes the = form; a project
generated by the 13.0.0-alpha.1 CLI has the caret form, so add the = to its
rullst and rullst-orm lines. Since 13.0.0-alpha.3 rullst and
rullst-orm have no default features and compile no SQLx driver on their own,
so a hand-written manifest selects the database profile it uses, as
cargo rullst new does (see the Minimal default features row):
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["strict-postgres"] }
This inventory covers the 13.0.0-alpha.4 development source. Revisit it as the remaining
delivery priorities land. It does not promise that every
existing application, provider account or deployment works without review.
To find the rows that affect an application, run cargo rullst upgrade --dry-run
with the v13 CLI: its rullst-upgrade-rules-v4 catalog reports MUST-CHANGE
and REVIEW findings at file:line, each naming the first-column title of its
row below, with the guidance to apply by hand. The
assisted upgrade tutorial
describes the workflow and classifies every row.
Rows without rules still need a reading. An application already on a 13.0
prerelease gets only the rules introduced after its version, and the plan says
how many earlier rules it skipped; --all-rules applies every rule
(rules by release).
An application that already runs 13.0.0-alpha.3 follows the shorter
alpha.3 to alpha.4 checklist;
one on 13.0.0-alpha.2 follows the
alpha.2 to alpha.3 checklist
first.
Changes from the published 12.1.0 source
| Area | Application impact |
|---|---|
| Existing runtime APIs | Source preparation changes supported dependency requirements; it invents no Rust rewrites. Review the explicit account-session schema migration below even when existing Rust calls still compile. |
| Account/session registry | Before serving requests with the new Auth recovery store, explicitly run SqlRecoveryStore::migrate() to add session details and the expiry index. Existing opaque sessions remain valid with unknown historical metadata. Configure expired-session maintenance and authorize new inventory/logout routes; see session management. The CLI manifest updater does not apply this SQL migration. |
| Private object storage | The optional storage-s3 candidate requires explicit signing credentials and production validation. Existing cloud constructors stay unconfigured; no bucket, IAM policy or credentials are inferred. See private storage. |
| R2 public URLs | Storage::r2(...).url(key) (and TenantStorage::url over R2) now returns StorageError::Unsupported instead of https://<account>.r2.cloudflarestorage.com/<bucket>/<key>. That S3 API endpoint requires a signature for every request, so the URL never loaded in a browser. Build public URLs from your r2.dev or custom domain in the application, or use a signed download with storage-s3. |
| Tenant local storage | TenantStorage reads, metadata and deletes on local storage return StorageError::Io when the tenant directory lies below a regular file (for example a file named tenants in the base path), on every platform. On Windows, 12.x and 13.0.0-alpha.1 returned NotFound there, as if the object were missing. |
| Recoverable Live UI | Existing LiveComponent and make:live output keep their original protocol. Opt in to live::recovery with explicit authenticated scope, periodic authorization, persistent revisions and atomic domain updates; mount its separate browser module. Full snapshots replace child DOM and never replay uncertain actions. See Live recovery. |
| Distributed tracing | Opt in to telemetry::distributed with approved operation names, upstream trust, a collector and owned flush/shutdown. The legacy initializer now uses compatible threaded OTLP/HTTP export and defaults to 127.0.0.1:4318/v1/traces; OTEL_EXPORTER_OTLP_ENDPOINT is a base URL, while OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is exact. Redirects and request bodies over 1 MiB are unsupported; the legacy initializer exports at most 64 spans per request (unless OTEL_BSP_MAX_EXPORT_BATCH_SIZE is set), honours HTTP_PROXY/HTTPS_PROXY/NO_PROXY and reports dropped batches on stderr, while the minimized profile ignores proxy variables. Review existing collector environment configuration; the tracing guide distinguishes both metadata policies. |
| Core queue | SQLite claims jobs in due-time order, so a due scheduled or handed-back job no longer waits behind later immediate jobs, and retry_failed_job keeps the attempt counter (it fences claims) instead of resetting it to 0. A job no worker has a handler for is failed with HandlerNotFound at its 720th claim attempt instead of being handed back forever. dispatch/dispatch_at reject empty names and names over 256 bytes, and Worker::run rejects job_timeout(Duration::ZERO), all with QueueError::InvalidConfiguration. Redis queue commands wait up to 10 s for a reply instead of 500 ms. Stalled recovery of a claim made without a lease (SQLite pop) may now happen up to two seconds after stalled_after, never before. Failed jobs that a 12.x process records in a Redis queue, including 12.x workers during a rolling upgrade, are not in the new failed-job index: they are neither counted nor evicted by the 10,000-entry retention, so retry them with retry_failed_job or remove them with purge_failed_jobs once every worker runs 13. |
| Core scheduler, cache and realtime | Scheduler::start returns the new SchedulerError::InvalidConfiguration for a zero task timeout; exhaustive matches in other crates are unaffected because the enum is #[non_exhaustive]. The Redis cache treats a zero TTL as an already-expired value (the key is removed) and a TTL beyond Redis’s expiry range as no expiry, as the memory driver does. PresenceTracker counts connections: call user_left once per user_joined, since a user stays online until the last leave. Channel::new clamps the capacity to 65,536. The Traffic Shield database probe gives up at max_db_latency and records at least that latency. A Live recovery session no longer counts its own callback and send time as peer silence. TenantCache now writes % as %25 and : as %3A in the tenant segment of its keys (tenants:a%3Ab:<key>), so entries of tenant IDs containing those characters start cold, and while 12.x and 13 instances share the cache a forget on one version does not remove the other’s entry for such a tenant; rely on TTLs or finish the rollout before depending on invalidation. |
| Core memory cache sweeps | Cache::memory() sweeps expired entries after a number of operations equal to a quarter of the entries the previous sweep kept (at least 256), instead of every 256 operations, so the sweep cost per request no longer grows with the cache. Reads still never return an expired value; unread expired entries may stay in memory longer in a large cache. |
| Studio queue monitor | Studio lists queue records with the new QueueDriver::list_job_previews (at most 2 KiB of each payload and error) instead of list_all_jobs. Custom drivers keep working through the default projection of list_all_jobs; override list_job_previews to cut values in the store. |
| Studio API playground | Studio::with_openapi now takes a utoipa 6 OpenApi. Applications that build their document with utoipa 5 must upgrade to utoipa 6 (and utoipa-axum 0.3 when they use its router helpers). Studio no longer embeds Swagger UI (its utoipa-swagger-ui build dependency needs a zip release that crates.io has yanked): /studio/api is now a server-rendered, read-only reference of the operations, /studio/api/ redirects to it and /studio/api/openapi.json still serves the document. There is no in-page “Try it out”; send requests with an external client. |
| Studio local access and table view | LocalStudioAccess::loopback_only() now accepts an IPv4-mapped loopback peer (::ffff:127.0.0.1, reported by a dual-stack HOST=:: listener) instead of returning 403; other mapped addresses stay rejected. The table view, and the public get_any_value_as_string, now report a present value that no text, integer, float or Boolean codec decodes (for example a non-UTF-8 BLOB) as unreadable instead of NULL, and a stored text NULL is no longer styled as SQL NULL. |
| LMS blueprint | --blueprint lms now generates a small starter: catalog, modules, lessons, an accessible player, enrollment, owner-bound progress, login and a Nexus admin. The complete Academy scaffold (schools, roles, quizzes, activities, scores, automation, notifications, publication, privacy lifecycle) and --lms-modules were removed; existing generated applications keep their code. Registration no longer generates the school-membership hook, and the learner dashboard no longer claims a default demo school: it points to the course catalog. Since 13.0.0-alpha.4 this starter is the examples/lms application instead (see “CLI starters moved to the examples”). |
| Consumer generators | make:supervision was removed with the complete Academy LMS it targeted; integrate rullst-supervision directly. make:privacy and make:age-gate now target only the SaaS starter (--blueprint saas, which requires --tenant-ref). |
html! event handlers | A dynamic value in an on*, hx-on or hx-on-* attribute (for example onclick={handler}) no longer compiles, because escaping cannot make data safe inside JavaScript. Static handler strings still compile; move data-driven handlers into a nonce’d script with addEventListener and pass values through data-* attributes. The default CSP already blocked such inline handlers. |
html! returns Markup | Since 13.0.0-alpha.2, html! returns rullst::html::Markup (escaped HTML) instead of String. Interpolating a Markup inserts it as HTML, so a fragment built by another html! call is no longer shown as escaped text and needs no RawHtml; String, &str and numbers are still escaped. A function declared -> String that returns html! no longer compiles: declare it -> Markup. RawHtml(html! { .. }) no longer compiles: interpolate the Markup directly. Collect lists with .collect::<Markup>() (collecting Markup into a String does not compile) and use Option<Markup> for optional parts. render_page/render_page_with_lang take impl Into<Markup> (wrap a String of trusted HTML with Markup::from_trusted), and Live::mount and native #[island] functions return Markup. A handler can return Markup (text/html; charset=utf-8), Html(html! { .. }) still compiles, Markup compares with and reads like a &str, and .into_string() returns the String. See html! returns Markup. |
| Core request validation | ValidatedForm/ValidatedJson return 413 for oversized bodies and 415 for a wrong content type (HTMX requests receive the same code in X-Rullst-Validation-Status); other read failures stay 400. Nested errors are keyed address.zip and items[0].name, and ValidationError formatting no longer includes submitted values. Update clients or tests that expected 400 for these cases. |
| Core feature flags and scheduler | A user outside a declared A/B split now resolves to "disabled" instead of falling through to the next driver, and a negative database rollout_percentage means 0%. Scheduler::task uses POSIX weekday numbering (0 and 7 are Sunday) with the day-of-month OR day-of-week rule in UTC; review numeric weekdays in existing schedules. RullstConfig::environment() now reports the environment the Server enforces, including a value read from .env. Percentage rollouts and A/B variants of the Env, TOML, Memory and DB drivers use a versioned SHA-256 bucket hash instead of std’s DefaultHasher, so upgrading reassigns users to buckets once (percentages and variant weights are kept). End experiments whose assignment must not change before upgrading, and keep the period in which 12.x and 13 replicas both serve short, because they assign the same user differently. |
| Memory feature-flag splits | A MemoryFeatureDriver::override_variants flag (including FeatureManager::overrides()) no longer reports enabled/enabled_for as true for everyone. Like the Env, TOML and DB drivers, enabled_for is true only for identifiers assigned a variant named "enabled"; like the Env and TOML drivers, enabled is false. Gate A/B code on variant. |
| Database feature-flag splits | A rullst_feature_flags row whose variants column holds an A/B split now makes DbFeatureDriver::enabled (and therefore feature::enabled when the DB driver answers) false, as in the Env, TOML and Memory drivers. It was true when rollout_percentage was empty. enabled_for and variant are unchanged; gate A/B code on variant. |
| Security headers | Core and both Security header layers preserve an endpoint’s exact Referrer-Policy: no-referrer, including when it occurs among duplicate values. Other endpoint values still yield to SecureHeadersLayer and CspSecurityLayer; the Core baseline now keeps them (see Core security header precedence). Review handlers that intentionally set this more restrictive policy. |
| Core WAF signatures | waf_middleware, which Server mounts in staging and production, now refuses injection structure instead of SQL and shell keywords. Ordinary text such as “Please select an option”, “Delete my account” or “curl the API with your token” in a query, Referer, cookie or body is no longer rejected with 403. A quote followed by or/and and a comparison or by a SQL comment, a ; followed by a SQL statement, union [all] select, SQL probe functions (sleep(, waitfor delay, information_schema…) and a shell metacharacter (;, |, &&, a backtick, $() followed by a command name are still refused, also when /* */ comments separate keywords; XSS and traversal signatures are unchanged. JSON bodies are inspected key by key and string by string after decoding, so a \u003c escape no longer hides <script and JSON quotes no longer join a signature. The WAF remains a coarse baseline: keep parameterized SQL and shell-free process APIs. |
| Core security header precedence | headers_middleware (the staging and production baseline in Server) now adds each security header only when the response has none, so an explicit value from a handler or an inner layer (Content-Security-Policy, X-Frame-Options, HSTS, Referrer-Policy, Permissions-Policy, COOP, CORP, COEP, X-XSS-Protection) wins over the baseline instead of being replaced. When rullst_security::SecureHeadersLayer ran inside it, which now marks its responses with the new rullst::security::SecurityHeadersApplied extension, the baseline leaves all of those headers alone, so the headers its SecureHeadersConfig omits (None) stay absent. Cache-Control: no-store for responses without a cache policy and the shared CspNonce are unchanged. Review handlers and layers that set a weaker value (for example Referrer-Policy: unsafe-url or a CSP with 'unsafe-inline') and relied on the baseline to replace it. |
| AI provider streaming and stop reasons | AnthropicProvider and GeminiProvider now implement StreamingAiProvider, and their capabilities() report streaming: true and explicit_cancellation: true; code that chose a transport from those flags now streams them. Gemini’s non-streaming chat/prompt now fail with AiError::ApiError when the candidate’s finishReason is MAX_TOKENS or a safety-class reason (SAFETY, RECITATION, LANGUAGE, BLOCKLIST, PROHIBITED_CONTENT, SPII, IMAGE_SAFETY) instead of returning the partial text, matching the Anthropic and OpenAI behaviour; raise the output limit or handle the error. The OpenAI-compatible stream (OpenAiCompatibleProvider with with_streaming(), used for OpenAI, DeepSeek, local servers and loopback Ollama) likewise fails with AiError::ApiError when a chunk reports finish_reason length or content_filter instead of completing with the text received so far. |
| AI image-beacon guardrail | The data_exfiltration check inspects every Markdown image even when the text contains no literal http:// or https://: scheme-relative (//host), slash-less (http:host), backslash and reference-style beacons (with the definition anywhere in the text), unterminated destinations and labels containing a code span, raw HTML or an autolink that could hide a ] (or a < followed by a bracket before the next >) are blocked; bracket-free constructs such as Vec::<u8> or Box<dyn Fn()> inside vec![...] no longer make a label ambiguous. It decodes CommonMark backslash escapes and entity or numeric character references in destinations, so  is blocked like the literal URL; an unrecognized named reference (for example é) makes that destination untrusted. Images and definitions inside code spans and closed fenced or indented code blocks are ignored, as CommonMark never renders them (code whose extent depends on an ambiguous layout is still read); lines end at LF, CRLF or a bare CR and only spaces and tabs make a line blank, so a bare CR or a no-break-space line can no longer turn a rendered image into apparent indented code. A reference image whose ASCII label matches no definition, such as vec![x] or routes![...] in Rust code, renders as literal text and is not blocked even next to a remote URL; a non-ASCII label, a label with a backslash-escaped bracket (read with its escapes, as [a\]b]) or an unterminated label is still blocked when the text names a remote URL, and a reference image whose definitions are all local is not blocked by an unrelated link. An inline image whose destination and title are not certainly valid (such as ) is also judged by the shortcut reference CommonMark falls back to, matched with Unicode case folding. |
| AI RAG tenant tags | RagPipeline::answer checks the tenant tag of every document the retriever returns, including those after the context budget is exhausted, and fails with RagError::InvalidDocument (audited as ContextRejected, no provider call) instead of answering when any of them belongs to another tenant. |
| Anthropic provider output | AnthropicProvider requests up to 16,000 output tokens instead of a fixed 1,024, and a reply whose stop_reason is max_tokens, model_context_window_exceeded or refusal now fails with AiError::ApiError instead of returning the partial text as a complete answer (so StatefulChat does not store it and RagPipeline audits GenerationFailed). Choose another limit with the new with_max_tokens; a longer reply can take longer than the 30-second default request deadline, so raise it with with_request_timeout. |
| OpenAI provider output | OpenAiProvider vision requests no longer send max_tokens: 1024 (deprecated by OpenAI and rejected by reasoning models); like text requests they leave the output limit to the model. A chat, vision, JSON or structured-output reply whose finish_reason is length or content_filter now fails with AiError::ApiError instead of returning the partial text. DeepSeek and OpenAiCompatibleProvider are unchanged. |
| Ollama host | OllamaProvider::new and AiClient::auto() read OLLAMA_HOST the way Ollama does: a scheme-less value such as 127.0.0.1:11434, localhost or [::1] now means http with port 11434 unless a valid port is named (ollama.com alone means https://ollama.com:443), instead of producing a URL every request rejected. Values with http:// or https:// are used as before. |
| AI chat memory keys on MySQL/MariaDB | SqlChatMemory tenant and conversation IDs compare case-sensitively on every backend; new MySQL/MariaDB tables use ascii_bin key columns, and prepare_schema never alters an existing table. On a table created by 12.x, an ID that differs from a stored one only by letter case fails closed (ChatMemoryError::InvalidConfiguration, ConversationNotFound or RevisionConflict). Stop chat-memory writers, back up both tables, review conversations that 12.x may have merged and apply the migration in the AI README. PostgreSQL and SQLite tables are unchanged. |
| Core CSRF on HEAD | csrf_middleware handles HEAD like GET: it installs the request-scoped CsrfToken and, when the request carries no CSRF cookie, sends the same Set-Cookie that GET would. A get route that extracts Extension<CsrfToken> now answers HEAD (monitors, crawlers, curl -I) with its GET status instead of 500. |
| Hot-reload machine endpoints | Server::new_hot(...).with_machine_endpoints(policy) now authenticates the policy’s exact routes in the development hot-reload server, as the static server already did in every environment. Previously hot reload ignored the policy and those routes ran unauthenticated; development machine clients must now send the configured credentials. |
| Core JSON PII masking | With enable_pii_masking, JSON string escapes are decoded before masking, so \uXXXX hex digits never join a card-number run (which produced invalid JSON such as \u****), while escaped digits and @ are masked like their literal characters. A masked character is written as a literal *; unmasked escapes keep their original spelling. |
| Core PII masking of range responses | With enable_pii_masking, a 206 Partial Content response whose range would be masked now becomes 502 with Cache-Control: no-store instead of a rewritten 206 that kept its status but lost Content-Range. A range that needs no masking passes through unchanged. A multipart/byteranges response, previously passed through uninspected, is now checked part by part and withheld the same way when a textual part would be masked or when the body cannot be split exactly (invalid boundary or delimiter line, more than 256 parts, a part header block over 8 KiB, no close delimiter). |
| Security DLP, RASP and honeypot | DlpResponseLayer now also masks XML (application/xml, +xml), YAML and application/javascript responses, as it already did for text and JSON; review clients that compare such bodies byte for byte. A 206 Partial Content response whose range contains a masked secret now becomes 502 with Cache-Control: no-store instead of a rewritten 206 without Content-Range. A multipart/byteranges response, previously passed through uninspected, is now checked part by part and withheld the same way when a textual part contains a secret or when the body cannot be split exactly (at most 256 parts and 8 KiB of headers per part). A honeypot ban TTL beyond the monotonic clock (for example Duration::MAX in HoneypotState::try_with_limits) now bans until the furthest representable deadline instead of never banning, and telemetry lists a ban only when one was stored. redact_secrets no longer increments dlp_secrets_masked or emits DLP_SECRET_LEAK_PREVENTED events (it counts only log_redactions), so dashboards stop reporting log lines as blocked responses. RaspSecurityLayer now serves requests whose User-Agent carries the stock PowerShell product token (PowerShell/7.4.1, WindowsPowerShell/5.1…) instead of returning 403; powershell elsewhere, or as execution syntax in that header, still blocks. HoneypotLayer telemetry (honeypot_route_hits, ban reasons and live events) names the configured trap path instead of the request’s letter case, so case variants share one route counter. |
| Security audit log lines | StdoutAuditLogger writes actor, action and resource as quoted, escaped strings (actor="bob" action="login") instead of raw text, so a value with spaces, = or a line break can no longer forge fields or lines. Update log parsers and alerts that match the unquoted 12.x form. |
| Connect generic OIDC | OidcProvider no longer rejects an ID token or userinfo response without the optional name claim (Missing name in id_token/userinfo); ConnectUser::name falls back to given_name/family_name, preferred_username, nickname and may be empty. Applications that relied on a non-empty name must handle the empty case. An ID token without a kid header is now verified when the issuer’s JWK Set holds exactly one key that fits the token’s asymmetric alg, instead of failing with Missing 'kid' header; other sets still fail closed, and a kid-less symmetric token now reports the algorithm error before any JWKS request. |
| Age assurance | Optional rullst-privacy APIs and make:age-gate add an authenticated native declaration journey. Existing apps acquire no age policy, verified age, guardian relationship, replay storage or camera capture by upgrading a dependency. |
| Optional consent and export | make:privacy adds explicit versioned choices, effective withdrawal of the demonstrated optional greeting and an authenticated own-account name/email export. It requires reviewed application installation and explicit consent-store initialization. It is not a complete export of all application data or automatic worldwide legal compliance. |
| Project context | New projects receive application instructions and a bounded metadata map. generate:ai-context replaces recognized legacy source-concatenation output, preserves existing AGENTS.md, and adds a non-writing --check. See the context guide for size/path/configuration limits. |
| Typed API generation | generate:api is an explicit additive OpenAPI profile with generated Rust codecs and a TypeScript client. Install/review the schema and application handlers; existing scanning generators and routes are not migrated automatically. See the supported profile. |
| CLI generator output | make:migration:auto reads #[orm(table_name = "...")], qualified #[derive(rullst_orm::Orm)] and a table option written after other #[orm(...)] options, so it diffs the declared table instead of a guessed <struct>s table. make:live imports rullst::async_trait instead of the undeclared async_trait crate; edit components generated earlier the same way. make:scalar imports rullst::web::axum::Router instead of the undeclared axum crate. eject writes a template that compiles (Axum through rullst::web::axum) and wraps its placeholder routes in the configured security baseline; it still does not carry over application routes, so --force output needs the routes moved from src/main.rs.rullst-backup. foundry:deploy uploads the binary to /opt/rullst/<app>/incoming/<bin>.upload (created 0700 for the SSH user during provisioning) instead of /tmp, and the remote step requires stat and sha256sum to check its owner and digest. foundry:init creates a missing .gitignore, no longer treats a comment or !Foundry.toml as protection, and writes Foundry.toml with mode 0600. make:model rejects names such as Match or Type whose module would be a Rust keyword, as make:resource already did. make:migration keeps leading m characters of the name (modify_users previously became odify_users) and rejects names that are not ASCII letters, digits, _ or -; generators that refresh src/migrations/mod.rs now fail on an m*.rs file whose name is not a valid module name instead of writing an unparsable registry. make:controller --api and make:resource --api handlers extract Json<CreateDto>/Json<UpdateDto> instead of Form, so clients must send application/json; controllers generated earlier keep Form until edited. generate:ts also scans src/lib.rs and turns {id}/{*path} captures into URL-encoded method arguments; regenerate rullst-client.ts, whose parameterized methods change signature. Re-run hook:install to refresh a managed commit-msg hook: it now accepts Git’s merge, revert and fixup!/squash!/amend! subjects. generate:openapi takes an operation summary only from the handler with the exact name, not from an earlier function whose name starts with it (show_archived for show). make:worker handlers no longer print the full job payload; existing workers keep their logging until edited. Fixture-mode make:billing (and SaaS starter) checkout/portal handlers answer a database failure with a generic Billing identity is unavailable 500 and log the error server-side. make:migration:auto ignores the ORM migrations table and rullst_* tables, and prints destructive-only differences instead of writing and registering a comment-only auto_sync migration on every run. hook:install fails when core.hooksPath selects another directory, instead of reporting success for wrappers Git never runs. inspect schema prints the ORM model schema derived from #[derive(Orm)] structs when no project-provided rullst-schema.json exists, instead of asking for a file that nothing generates. |
| CLI generator robustness | generate:models with --driver postgres or --driver mysql casts and aliases the information_schema columns it reads, so PostgreSQL 12+ and MySQL 8 schemas introspect instead of failing to decode sql_identifier or panicking on upper-case MySQL labels; unexpected metadata is now an IntrospectionError instead of a panic. make:migration:auto declares columns from the field’s Rust type (integer, big_integer, float, boolean or string) instead of TEXT for every field, declares non-Option fields of a new table NOT NULL, adds a required column to an existing table as NOT NULL DEFAULT 0/0.0/'', and fails without writing when a field type has no mapping (for example Vec<u8>, Uuid, a tuple or an enum) or a required date/encrypted/JSON column would leave existing rows NULL; mark such fields #[orm(skip)] or add them with make:migration. cargo rullst auth and make:mfa refuse a project without a SQL migration runner (pub mod migrations; plus a get_migrations() call in src/main.rs/src/lib.rs), such as a --no-database starter, instead of writing a users or MFA migration nothing compiles or applies; the generated User model, users migration and auth controller name the ORM as rullst::orm::Error instead of the undeclared rullst_orm crate. inspect routes no longer follows symlinks under src/ (a link cycle made it effectively hang, and a link out of the project listed routes from outside it); route files reached only through a symlink are no longer listed. generate:ts clients send the rullst_csrf cookie value as X-CSRF-Token on state-changing requests and set credentials: 'same-origin', so they pass csrf_middleware instead of receiving 403; the constructor gains an optional RullstClientOptions argument (csrfToken, credentials). Regenerate rullst-client.ts. make:live now requires a Rullst project root, and make:live, make:controller and make:middleware reject names whose module or type is not a non-keyword Rust identifier (such as ../notes, self or Bad.Name) instead of writing outside src/live or an unparsable mod.rs. New projects’ .gitignore also lists *.duckdb, *.duckdb.wal, *.sqlite-shm and *.sqlite-wal, matching .dockerignore; add them to the .gitignore of an existing project, particularly one created with --duckdb (DUCKDB_PATH=analytics.duckdb). |
| ORM database-managed columns | #[orm(skip)] means “not a database column for the ORM”: generated filters, ordering, grouping and select reject it with a Validation error that now says so and names #[orm(db_default)]. A column the table has that ORM writes must not touch (such as users.is_admin and users.session_epoch) is declared #[orm(db_default)]: read, filterable (where_eq("is_admin", true)), never written by INSERT, save() or update_partial(), and included in to_json(), audit and event payloads and query-cache entries (where the skipped field previously came back as Default). New SaaS, LMS and cargo rullst auth User models use it; in an application generated by 13.0.0-alpha.2 replace #[orm(skip)] with #[orm(db_default)] on those two fields (cargo rullst upgrade reports V13-ORM-SKIP-MANAGED-COLUMNS). |
| ORM dirty tracking | Opt-in: a model that declares #[sqlx(skip)] pub snapshot: RowSnapshot (rullst_orm::RowSnapshot, also rullst::db::RowSnapshot) makes save() of a loaded record write only the columns whose value changed and refresh the others from the stored row (RETURNING * on PostgreSQL and SQLite, a re-read on MySQL/MariaDB). An unchanged save runs no UPDATE and no updating/updated/saved observer, after_save hook, audit entry or event, and without a before_save hook, policy or observers opens no transaction. update_partial() on such a model writes only the patched columns that differ. Call mark_all_dirty() before save() where code relied on overwriting concurrent changes. UPDATE OF <column> triggers fire only for written columns, and a before_save hook that stamps updated_at on every call makes every save write (check is_dirty() first). Struct literals need snapshot: RowSnapshot::default(), and models deriving Serde need #[serde(skip)] on the field. make:model adds the field to new models; from 13.0.0-alpha.3 every SQLx model the CLI writes declares it (generate:models, cargo rullst auth, make:billing, make:chat-session and the Blank, Blog, ERP, Portfolio, SaaS and LMS starters), so new projects save only changed columns. Models without it, including those of projects generated earlier, are unchanged. |
| ORM partial updates | update_partial() now merges into a freshly loaded row and runs full-save policy/hooks/audit/post-commit behavior. It performs full-row SQL and refreshes the caller object; review audit identity, triggers, unsaved local fields and outer rollback. New save_with_tx is explicit. See the migration contract. |
| ORM derive checks | Some models that compiled on 12.x now fail at compile time instead of misbehaving at runtime: belongs_to without foreign_key defaults to <related model>_id (it previously used <this model>_id, joining on the wrong column); local_key on belongs_to/morph_to and related_key on has_one/has_many/morph_one/morph_many are rejected because those relations ignore them; and searchable models need a lowercase, Scout-valid table name. Add an explicit foreign_key, remove the ignored option or set #[orm(table = "...")]. |
| ORM queries and cache | count()/paginate() totals count DISTINCT rows and GROUP BY groups; offset chunk() pages are ordered by primary key unless order_by is set; SecretString columns are rejected in filters, ordering, grouping and pluck_*. While a transactional stream() is open, other ORM calls on that transaction return Validation instead of waiting. Models without a tenant_column share one global query-cache partition, so cache keys built manually with query_cache::query_key inside with_tenant for such models no longer match generated keys. Scout-backed search() results follow the engine’s ranking unless order_by is set. Generated writes now bind #[sqlx(json)] fields as SQLx Json (JSON on PostgreSQL/MySQL instead of the field’s own SQL type), so such a field type needs Serialize; a type that implemented only Encode no longer compiles. |
| ORM query-cache index | Generated .remember(...) keys move to the rullst:orm:cache:v4: prefix, and each table index becomes a Redis sorted set (<prefix>:index) scored by entry expiry instead of the <prefix>:keys set. Entries written under the previous prefix are not read again and expire through their TTL, so caches start cold after the upgrade; during a rolling upgrade a write on an instance of the other version does not invalidate the new entries before their TTL. Index members of expired entries are pruned on each cache write and no longer count toward the 10,000-key PostCommit invalidation cap. Tools that inspected the old :keys set must read the sorted set. |
| ORM Redis model hashes | Generated save_to_redis/get_from_redis/increment_redis_field hashes move from the shared 12.0/12.1 key orm:<table>:<id> to namespaced keys (rullst:orm:hash:v1:<namespace>:<scope>:table-<digest>:<id>), and tenant models require with_tenant(...). For a model without a tenant scope nothing has to be run: get_from_redis still reads the 12.x hash while the namespaced one is missing, and the next save_to_redis or increment_redis_field moves it to the namespaced key in one Redis script (an existing namespaced hash wins and the stale 12.x hash is removed); a 12.x hash with #[orm(encrypted)] fields holds them in plaintext and fails closed until save_to_redis() rewrites it. Tenant models never read the 12.x key, which every tenant shared: until you run the one-time procedure in Model hashes stored by 12.0 and 12.1, get_from_redis returns None and increment_redis_field starts from zero. A 12.x instance never reads a moved hash and a 13 instance ignores 12.x writes once the namespaced hash exists, so stop 12.x writers of these hashes before 13 instances write them instead of mixing versions. |
| ORM outbox keys on MySQL/MariaDB | Outbox streams, event keys and claim tokens compare case-sensitively on every backend; new MySQL/MariaDB rullst_outbox tables declare those columns ascii_bin. A table created by 12.x keeps the server’s case-insensitive collation without any error, so keys that differ only by letter case still share idempotency and claims. Run Outbox::install() once, for example from a new migration (an OutboxMigration that already ran is not run again), to convert the table in place; it rebuilds the table, so schedule it like other DDL. See the transactional outbox tutorial. PostgreSQL and SQLite tables are unchanged. |
| ORM audit payloads on MySQL/MariaDB | New MySQL/MariaDB rullst_audits tables store old_values, new_values and restore_patch as LONGTEXT. A table created by 12.x keeps TEXT (64 KiB): create_audit_table only logs a warning, and an audited change with a larger payload still fails in strict SQL mode (or is truncated otherwise). Apply ALTER TABLE rullst_audits MODIFY old_values LONGTEXT, MODIFY new_values LONGTEXT, MODIFY restore_patch LONGTEXT in a maintenance window, as described in auditable revisions. |
| Turso migrations | TursoStore::rollback_last and TursoOrm::rollback_last now refuse, with the same drift error as migrate, a latest migration whose digest (its up and down statements) differs from the recorded one, instead of running the changed down statements and deleting the history row. Restore the applied definition to roll it back. |
| Schema table names on PostgreSQL | Schema::create and Schema::drop_if_exists lower-case the quoted table name on PostgreSQL, matching the unquoted names generated model SQL uses, so Schema::create("UserProfiles", ...) now creates userprofiles. A table an earlier version created as "UserProfiles" is not dropped by drop_if_exists("UserProfiles") and stays unreachable by generated SQL; rename it with ALTER TABLE "UserProfiles" RENAME TO userprofiles. MySQL/MariaDB and SQLite are unchanged. |
Legacy SecretString ciphertext | Pre-v12 SecretString values, which carry no key ID, now decrypt with the current key and then each RULLST_ENCRYPTION_KEYRING key (default first), so they stay readable after a documented rotation. When the current key does not match, a malformed keyring now fails the read with PrivacyError::EnvError instead of DecryptionFailed. |
SecretString client input | SecretString’s Deserialize still decrypts any serde envelope under the configured key or keyring, and that envelope binds no model, field or owner. Review request types that deserialize a SecretString from client input, including models used directly as JSON bodies, and add #[serde(deserialize_with = "rullst_orm::privacy::deserialize_plaintext_secret")] (or deserialize_optional_plaintext_secret, v13 prerelease API), which rejects envelope-shaped values without decrypting them. |
ORM protected values and SecretString serialization | New audit rows and committed events (including Redis orm:events:*) write #[orm(encrypted)] and #[orm(masked)] values as "***", Scout documents omit them, and save_to_redis stores encrypted fields as the SQL envelope; a change to such a field makes its audit revision non-restorable. SecretString serializes as an encrypted RULLST:v2 envelope ("***" in generated projections) instead of its plaintext, and serializing it without RULLST_ENCRYPTION_KEY fails. Audit rows, search documents and events written by 12.x are not rewritten: purge or reindex them if they may contain plaintext. A 12.x process that deserializes a SecretString serialized by 13 (queue jobs, caches, JSON exchanged between services) takes the envelope as the plaintext value, so upgrade every reader before the writers and do not exchange such payloads between versions. |
| Offline Redis mock | The offline RedisDataStore mock returns equal sorted_set_top scores in descending lexicographic member order, like Redis ZREVRANGE, instead of ascending order. Update tests that relied on the old tie order. |
| SQLite DSN paths | The missing-file preparation before connecting percent-decodes the SQLite DSN path as SQLx does, so sqlite://data%20dir/app.db creates data dir/app.db, the file SQLx opens, instead of a stray data%20dir/app.db. Stray encoded-name files left by earlier versions can be removed. |
| Auto-healing diagnostics | SchemaErrorInterceptor::diagnose_sql_error returns None for constraint violations, already exists errors, other messages that merely mention a relation, and names that are not plain identifiers. Missing-table suggestions use SQLite or MySQL DDL when the message comes from those drivers, and SQLite no such column and MySQL Unknown column/Table ... doesn't exist messages are recognized. |
| ORM migrations | The Migration trait gains within_transaction() (default false, v13 prerelease API). Returning true makes the runner apply up()/down() and its migrations record in one Orm::transaction; only statements that join it (Schema, Orm::raw(..).execute(), execute_query!, generated model methods) are covered, and MySQL/MariaDB DDL still commits implicitly. The template written by the ORM artisan make:migration now opts in, so such a migration that sends queries to Orm::pool() directly must return false. Existing migrations keep the separate statements. db:seed now fails, without running any seeder, while a migration registered with artisan! is not recorded in the migrations table; run db:migrate first. |
| ORM transactional migrations and the pool | Since 13.0.0-alpha.3, while the up()/down() of a migration that returns true from within_transaction() runs, Orm::pool(), Orm::try_pool(), Orm::read_pool(), Orm::try_read_pool() and Orm::begin_transaction() return Error::Validation naming the migration, instead of a connection outside the migration’s transaction; the run then fails (DatabaseError with Transaction failed: Validation error: ...) and the migration rolls back unrecorded. Send that SQL with Orm::raw(sql).execute(), execute_query! or Schema, or return false from within_transaction(). Migrations that keep the default false are unaffected. |
| ORM instance mutations | save() of a row deleted after it was loaded, and delete()/force_delete() of a missing row, now fail with RecordNotFound (tenant models keep their tenant-scope Validation error) instead of returning Ok and running observers, audit and post-commit effects. A second soft delete() of a trashed row fails the same way instead of re-stamping its deletion time, and restore() of a missing or live row is a no-op on every model. cascade_soft_delete now also trashes children that the child model’s global_scope hides. A model whose only persisted field is id can now be inserted. An auditable save() locks the row it diffs (FOR UPDATE on PostgreSQL/MySQL), deleted/force_deleted/restored audit entries record the persisted row instead of the in-memory handle, and that read, revision restores, restore() re-reads and belongs_to_many pivot loads now fail with the configured query timeout instead of waiting indefinitely. Callers that deleted idempotently must accept RecordNotFound. |
| ORM constraint errors | A unique or primary-key violation now returns Error::UniqueViolation { constraint, .. } and a foreign-key violation Error::ForeignKeyViolation { constraint, .. }, from generated writes, query builders and Orm::raw, instead of Error::DatabaseError(String). Their message names only the constraint (PostgreSQL) instead of quoting the database’s text, so code that matched DatabaseError or searched the message for UNIQUE constraint failed, duplicate key or Duplicate entry must match the variants instead. Orm::transaction returns them unchanged too (see ORM transaction errors). |
| ORM transaction errors | Orm::transaction returns Result<R, E>, where E is its closure’s error type and must implement From<rullst_orm::Error> and Display, instead of Result<R, rullst_orm::Error> (13.0.0-alpha.3). After the rollback the closure’s error reaches the caller unchanged instead of Error::DatabaseError("Transaction failed: ..."), so a UniqueViolation, ForeignKeyViolation or the application’s own error keeps its type; begin, commit and post-commit failures arrive through E::from. A closure whose error type lacks From<rullst_orm::Error> (such as String) no longer compiles: return rullst_orm::Error or an application error that wraps it. Code that matched DatabaseError on the result must match the closure’s own error instead; cargo rullst upgrade reports Orm::transaction calls in files that mention DatabaseError (V13-ORM-TRANSACTION-ERRORS). |
| ORM encryption key settings | RULLST_ENCRYPTION_KEY, RULLST_ENCRYPTION_KEY_ID and RULLST_ENCRYPTION_KEYRING are now also read from .env in the current working directory when the process environment does not set them, as APP_KEY already was; the process environment still wins and the file is never exported. A .env that sets them now takes effect for #[orm(encrypted)] fields and SecretString, so remove stale entries; a malformed .env now fails such a lookup when the process lacks the variable. |
| ORM query values | RullstValue is now #[non_exhaustive] and gains I64(i64) and Null, so a match on it outside the ORM needs a wildcard arm. i64 and u32 values now convert to I64 (bound as BIGINT) and Option<T> to T’s value or Null, so where_eq("lesson_id", Some(3)) and BIGINT filters compile. Null binds as a text-typed NULL; on PostgreSQL cast it for a column of another type, and filter missing values with where_null. Integer literals still convert to Int (i32). The chrono types DateTime<Utc>, NaiveDateTime, NaiveDate and NaiveTime convert to the new DateTime, NaiveDateTime, Date and Time variants; strict driver pools bind them natively and the SQLx Any pool as ISO-8601 text. |
| ORM date/time columns | With a strict driver feature (strict-postgres, strict-mysql or strict-sqlite, which generated projects use), Blueprint::timestamps() now creates timestamp_tz() columns instead of TEXT DEFAULT CURRENT_TIMESTAMP: TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP on PostgreSQL, DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6) holding UTC on MySQL/MariaDB and RFC 3339 TEXT (2026-10-15T09:30:00.123+00:00) on SQLite. Read them as rullst::orm::chrono::DateTime<Utc>, set with Utc::now(), or as Option<DateTime<Utc>> marked #[orm(db_default)] so inserts keep the database default; a String field no longer decodes them on PostgreSQL or MySQL/MariaDB (on SQLite it still does, but the default text changes from YYYY-MM-DD HH:MM:SS to the RFC 3339 form). Only tables created from now on change, including a fresh database built by an existing migration that calls timestamps(); existing TEXT columns stay until a reviewed migration converts them, for example ALTER TABLE t ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at::timestamptz on PostgreSQL. The SQLx Any pool (the drivers-all default without a strict feature) cannot read date/time values, so there timestamps() keeps the TEXT columns and the new date(), time(), timestamp() and timestamp_tz() columns fail on PostgreSQL and MySQL/MariaDB with Error::Validation before any DDL runs. make:migration:auto declares chrono fields with those helpers instead of string(). Nexus now reads Date/DateTime fields as text on PostgreSQL and MySQL/MariaDB so native columns display, and writes them as typed values (see “Nexus dates and times”). cargo rullst upgrade reports String created_at/updated_at fields of SQLx models. |
| Schema builder constraints | Column gains the public field is_unique (set by Column::unique()), so building a Column { .. } with a struct literal needs it; use Column::new. Schema::create also runs the CREATE INDEX statements of index()/unique_index() on PostgreSQL and SQLite, and Blueprint::build() includes the table’s unique, foreign-key and CHECK constraints. Error gains UnsupportedSchemaChange { driver, change }, returned by Schema::table for changes SQLite cannot make to an existing table; a match on Error outside the ORM already needs a wildcard arm. |
| ORM sandbox tests | #[rullst_orm::test] keeps the function’s declared return type and returns the body’s value after the rollback, so a test returning Err now fails instead of passing. A test whose declared type does not match its body, or is not a Termination type such as Result<(), E: Debug>, no longer compiles; fix the signature. Post-commit effects of sandboxed writes (committed observers, cache invalidation, Redis events, Scout) no longer run, because the sandbox never commits; assert them in a test that commits through Orm::transaction. |
| ORM model SQLx options | A struct-level #[sqlx(...)] option other than default on a #[derive(Orm)] model, such as rename_all = "camelCase", no longer compiles: FromRow decoded the renamed columns while generated SQL used the field names, so writes or reads failed at runtime. Name the fields after their columns instead. |
| ORM trashed scopes | only_trashed() on a model without soft deletes now fails with Validation instead of returning, counting or bulk-deleting every live row as if it were trashed (an “empty trash” only_trashed().delete_all() deleted the whole table). Remove the call, or give the model a deleted_at field or #[orm(soft_delete)]. |
| ORM bulk soft deletes | On soft-delete models, delete_all() with with_trashed() or only_trashed() now fails with Validation instead of re-stamping the deletion time of rows that were already trashed, which kept age-based purges from ever seeing them expire. Bulk-delete live rows without these modifiers and purge trashed rows with force_delete(). |
| ORM joined scopes | Generated tenant and soft-delete predicates and the chunk_by_id keyset are now qualified with the model’s table (orders.tenant_id = ?, orders.deleted_at IS NULL, ORDER BY orders.id ASC), so a lazy belongs_to_many load or a join with a table that has the same columns no longer fails with an ambiguous column. Update tests that compare generated SQL text; Redis query-cache entries are keyed by the new SQL and are rebuilt on the next read. |
| ORM chunk bounds | chunk(), chunk_with_tx(), chunk_by_id() and chunk_by_id_with_tx() now honour an explicit limit(n) as a cap on the total rows handed to the handler and an explicit offset(k) as the starting point, instead of silently walking every matching row from the first one. Remove limit()/offset() from chunked queries that relied on them being ignored. |
| ORM eager pivot order | An eager belongs_to_many load now orders each parent’s related rows like the related query, so an order_by in with_<relation>_constrained applies, instead of following the order of the unordered pivot rows. Without an order_by the order remains unspecified. |
| PersonalData reports | compliance_schema() now reports the table #[derive(Orm)] uses (#[orm(table)] or the <struct>s default, previously the lowercased struct name) and lists in encrypted_fields/has_encrypted_data only #[orm(encrypted)] and SecretString columns; #[privacy] fields, which are not encrypted by the derive, are listed by the new ComplianceModel::personal_fields(). |
| Nexus derive | A field annotated #[nexus(primary_key)] is now the record key even when a later field is named id (previously the last of them won, and both were hidden and read-only). An annotation that contradicts the struct-level #[nexus(primary_key = "...")] no longer compiles; since alpha.2, several annotated fields declare a composite key (see “Nexus composite keys”). A non-integer derived key (a UUID or code) is now listed and entered in the create form instead of being hidden and read-only; mark it #[nexus(readonly)] or #[nexus(hidden)] to keep the previous behaviour for a database-generated key. Fields typed chrono::DateTime<Utc>, DateTime<chrono::Utc> or NaiveDateTime now get the validated date-time widget instead of free text. Rust integer fields (i8 to i64, u8 to u64, isize, usize, also under Option or kind = "number") now derive FieldKind::Integer { min, max } bounded by their type (u64/usize capped at i64::MAX) instead of Number, so Nexus rejects fractions, exponents and out-of-range values with 422; code comparing derived metadata with FieldKind::Number must expect Integer. |
| Nexus composite keys | NexusModel::nexus_pk_columns() (default: vec![nexus_pk()]) declares a composite primary key, and RegistryEntry gains pk_columns: Vec<&'static str> (empty means the single pk): code that builds a RegistryEntry with a struct literal must add pk_columns: Vec::new(). In #[derive(Nexus)], #[nexus(primary_key = "a, b")] or #[nexus(primary_key)] on several fields now declares a composite key (two annotated fields used to be a compile error); every column of a composite key is entered on create, even an integer one. A composite record is addressed by its values, each percent-encoded and joined by , (7,intro), in URLs, batch selections and the audit record_key. Registry validation rejects more than 8, repeated, unregistered or tenant key columns. |
| Nexus dates and times | Date and time fields are written as typed values instead of text, so native PostgreSQL DATE/TIME/TIMESTAMP/TIMESTAMPTZ columns accept edits (PostgreSQL writes go through a cast; a text column still stores text). #[derive(Nexus)] now maps chrono::DateTime<Utc> to the new FieldKind::DateTimeUtc (it was DateTime) and NaiveTime to the new FieldKind::Time; kind = "datetime_utc" and kind = "time" select them. A DateTimeUtc field’s datetime-local input shows and accepts wall times in the panel’s time zone, UTC unless Nexus::with_time_zone selects another NexusTimeZone (13.0.0-alpha.3; IANA zones need the nexus-tz feature): a value without an offset is a wall time in that zone, one with Z, ±HH:MM or ±HH is converted, and a text column stores RFC 3339 (2026-10-15T09:30:00+00:00). The handlers nexus_new_form, nexus_edit_form, nexus_create_record and nexus_update_record take an extra Option<Extension<NexusTimeZone>> argument, which only code calling them directly sees. Code comparing derived metadata with FieldKind::DateTime for a DateTime<Utc> field must expect DateTimeUtc. A Date (and the date of a date-time) with year 0000 is now a 422. A local date-time or time with a fraction is stored with chrono’s 3, 6 or 9 digits (10:00:00.5 becomes 10:00:00.500). Hand-written metadata keeps working; switch DateTime<Utc> fields to FieldKind::DateTimeUtc for UTC handling, as the starters now do for created_at/updated_at. |
| Nexus access in the starters | New SaaS and LMS projects mount Nexus with NexusAuthPolicy::local_development_or_application(middlewares::nexus_access::NexusAdmins): debug builds keep loopback access, and release builds admit the signed-in user whose new users.is_admin column (Boolean, default false, #[orm(db_default)] on User, editable in Nexus) is true, with no NEXUS_ADMIN_* credentials (their .env no longer lists them). Grant the first administrator with UPDATE users SET is_admin = TRUE WHERE email = '…';. To adopt this in an existing SaaS/LMS application, add a migration with table.boolean("is_admin").not_null().default(ColumnDefault::Integer(0)) (through Schema::table), the #[orm(db_default)] pub is_admin: bool field, and copy src/middlewares/nexus_access.rs from a new project. cargo rullst auth users also get is_admin, and its controller now sets is_admin: false when registering: a regenerated controller needs the field in an older User model. New Blog, Portfolio and ERP projects without NEXUS_ADMIN_* in a release build now start without Nexus (the ERP without its back office, serving only /health and /ready) and print one line naming the variables, instead of refusing to start; a weak or placeholder credential still stops startup. |
| Nexus credentials | NexusAuthPolicy::basic_from_env (and release builds of local_development_or_basic_from_env) reads NEXUS_ADMIN_USERNAME/NEXUS_ADMIN_PASSWORD from the process environment, then .env in the working directory only, through rullst::config::project_setting. It no longer loads .env (or a parent directory’s .env) into the process environment, so code that relied on that side effect must read its own settings; a malformed or unreadable .env now fails with the new NexusBuildError::InvalidDotenv instead of being ignored. |
| Nexus panel | A list sort_by naming a hidden column (for example a derived password_hash) now orders by the record key, as a Password column already did; bookmarked URLs that sorted by such a column show key order. A model without a visible text, textarea, e-mail or URL field shows no search box, and a q sent to it lists no rows instead of every row. With the debug loopback policy, an IPv4-mapped loopback peer (::ffff:127.0.0.1, reported by a dual-stack HOST=:: listener) is now accepted as loopback instead of receiving 403; other mapped addresses stay rejected. That policy also accepts a mutation with Origin: null when it carries Sec-Fetch-Site: same-origin (what browsers send under Referrer-Policy: no-referrer) and stamps Referrer-Policy: same-origin on responses that set none. A create or update rejected by validation now answers 422 with a plain-text message (Invalid <label> form: …) instead of an htmx out-of-band toast fragment, and a create without writable values answers 400 in plain text; the panel shows these messages, and HTML error bodies, as text. With required audit, a create whose primary key the administrator entered (a text or UUID key) now records that key in record_key instead of NULL. A saved local date-time is now stored as YYYY-MM-DD HH:MM:SS[.fraction] (the CURRENT_TIMESTAMP/SQLx text form) instead of the browser’s YYYY-MM-DDTHH:MM, and the space-separated form is accepted on input; values with an offset are stored as entered. Rows Nexus saved earlier keep their T form until edited again. FieldKind gains the Integer { min, max } variant and is now #[non_exhaustive]: a match on it outside Nexus needs a wildcard arm, and later kinds will not break it again. The AI assistant no longer renders <img> (or any other element outside a text-formatting allowlist) from a reply; images a model returns are dropped. |
| CLI migration catalog | rullst-upgrade-rules-v4 recognizes source majors 12 and 13 and adds v12 → v13 source rules: upgrade --dry-run (and --json, whose v1 findings gain kind, migration_row and migration_url) reports MUST-CHANGE and REVIEW findings linked to the rows of this table. update project verify, review and apply refuse a preparation with must-change findings and accept one with only review findings. v5/v6 applications, including their v11-era ecosystem crates, first upgrade to v12 with the v12 CLI; the v13 CLI no longer carries their rules. The installed CLI must belong to the exact target major. Preparations from an earlier catalog must be prepared and verified again. Downgrades still fail. When cargo fix or cargo check fails, the error also repeats the guidance of V13-CAPITAL-REMOVED and V13-MAIL-REMOVED findings: they name removed APIs but are review findings. |
CLI upgrade downgrades | cargo rullst upgrade (and the dashboard’s Safe Upgrade) now fails, before writing anything, when its target (the installed CLI version or --to) is older than a managed requirement’s lower bound or a Rullst package in Cargo.lock. It previously pinned every Rullst dependency to the older version and let cargo fix downgrade the lockfile. Install a CLI that is not older than the project. |
| CLI Foundry service environment | foundry:deploy adds PORT="<[app] port>" (default 3000) to the service environment unless [env] sets PORT, so the application listens where Caddy and the /health probe connect; previously a non-default [app] port produced a 502. An [env] PORT that differs from [app] port is now rejected; without [app] port, [env] PORT selects the proxied port. It also adds HOST="127.0.0.1" unless [env] sets HOST or RULLST_HOST, so the application no longer listens on every interface (0.0.0.0, the production default) beside Caddy; add HOST to [env] only if something other than the local Caddy must reach that port. |
| CLI Foundry build artifact | foundry:deploy uploads the executable that cargo build --message-format=json-render-diagnostics reports for the package instead of target/<profile>/<package name>, so CARGO_TARGET_DIR, build.target-dir, a workspace target directory and a configured build.target no longer upload a stale or missing binary. A package with several binaries needs package.default-run unless one is named after the package. |
| CLI generators | make:model --migration and make:resource skip the create-table migration when the model file or a *_create_<table>.rs/*_create_<table>_table.rs migration already exists, because its rollback would drop the live table; add schema changes with make:migration. generate:models/make:models-from-db refuse to replace an existing <table>.rs and append missing declarations to an existing mod.rs instead of rewriting it; move colliding files aside or use another --output. make:k8s, dockerize, nixify and generate:buildah no longer replace existing manifests, Dockerfile, flake.nix, .envrc or build_buildah.sh; move a file aside to regenerate it. cargo rullst auth rejects Turso-primary projects, refuses to replace existing user/auth files or to add a second users-table migration, enables the orm and auth features and registers its modules in the crate root. make:mfa now generates a server-side factor store (user_mfa_factors migration, replay-protected verification bound to the authenticated Extension<i32> user id) instead of a handler that verified a client-supplied secret; it enables orm and security, rejects Turso-primary projects and refuses to overwrite. Replace a previously generated mfa.rs that accepted a secret form field. |
| CLI operations | deploy --platform fly and --platform railway now exit non-zero when the installed flyctl deploy or railway up fails, instead of printing a hint and exiting 0; a missing provider CLI is still advisory. Update scripts that relied on the old exit status. update project verify resolves the candidate with cargo update --workspace instead of cargo generate-lockfile, so unrelated Cargo.lock pins are kept; verifications recorded with the old command no longer pass review/apply and must be run again. Project-copy Cargo/rustc commands pin RUSTUP_TOOLCHAIN to the caller’s toolchain (inherited value, otherwise rustup’s default) and ignore the project’s rust-toolchain(.toml); set RUSTUP_TOOLCHAIN to verify with a project-pinned channel. foundry:deploy runs the service as a dedicated rullst-<app> system account (it now requires useradd) in a sandboxed unit instead of root; existing /opt/rullst/<app>/data is re-owned, and the application can write only there, so move state written elsewhere (for example an absolute SQLite path) under that directory first. |
| CLI diagnostics and side effects | generate:diagram writes a marker comment at the top of diagram.md and refuses to replace a diagram.md it did not generate; the refresh after make:controller, make:model, make:middleware, make:worker, make:island, auth, make:billing, make:cors and make:jwt keeps such a file and prints a notice. A diagram from an earlier release (a single unmarked Mermaid block) is still replaced. Move a hand-written diagram.md aside to regenerate it. audit --compliance and audit --sbom fail instead of writing through a symlinked SECURITY_COMPLIANCE.md or sbom-cyclonedx.json. The audit source scans and generate:diagram no longer follow symlinked files or directories, so code reachable only through a link is not scanned; a source tree deeper than 64 levels or larger than 250,000 entries makes the audit fail as incomplete. audit --network now exits non-zero, with an ERROR network row in the report, when the TCP listener inventory (ss -ltnH, from Linux iproute2) cannot run, which is always the case on macOS and Windows; it previously reported a clean scan. In sbom-cyclonedx.json, only crates.io packages keep the plain pkg:cargo/<name>@<version> purl: git and other-registry packages gain a vcs_url or repository_url qualifier, path and workspace packages lose their purl, and these components carry a rullst:cargo:source property. Update SBOM consumers that matched those purls. pkg add without a package name and an unknown pkg action now exit non-zero instead of printing an error and exiting 0. deploy rejects an unknown --platform before scaffolding a missing Dockerfile and .dockerignore. make:k8s and the fly.toml written by deploy --platform fly derive names from the [package] name as a lowercase RFC 1123 label (my_app becomes my-app, the image my-app:latest, the host my-app.local); manifests generated earlier keep their names, so rename the resources or the image tag if you regenerate them. cargo install cargo-rullst no longer installs the internal sync-badges repository tool: it requires the new maintainer-tools feature, which the repository’s cargo sync-badges/cargo sync aliases enable. |
| CLI home screen | cargo rullst without a subcommand shows the new opening (the RULLST wordmark and v<version> · SECURE, FAST AND AI-NATIVE RUST FRAMEWORK) instead of the RULLST v… // SECURE • FAST • … tagline and the COMMAND INTERFACE READY line, followed by a summary of the detected project. Inside a project the main menu starts with dev, dash, scaffolding, database, doctor and deploy; the former Already have a project? operations remain under All Project Operations. From a project subdirectory, menu commands run at the project root. When standard input, output or error is not a terminal, CI is set or TERM=dumb, it prints the plain home and exits 0 instead of failing at the first prompt; scripts that matched the old banner text or relied on that failure must change. The opening animates on every run; since 13.0.0-alpha.4 RULLST_OPENING=daily limits it to the first run of a day (the date is stored under the user cache directory, rullst-ui-v1) and RULLST_OPENING=static never animates. See the CLI reference. |
| CLI security audit | Run from a package directory, audit now also scans the src of every workspace member below it (from cargo metadata) for unsafe code and parameterized routes instead of only ./src. audit --idor and audit --network skip each top-level #[cfg(test)] item on its own instead of ignoring the rest of the file after the first one, so routes and listeners declared after an early #[cfg(test)] mod tests; are now scanned and can report new findings. audit --idor also checks every quoted path on a route line, not only the first, so the later routes of a one-line routes! list are reported, and a comment between .route( and its path literal no longer hides that path from the check (place the marker on that comment line). Guard names in comments, doc comments or #[cfg(test)] items no longer satisfy an owner, role or admin classification. Classify such routes with an adjacent // rullst-access: marker. |
| CLI dashboard audit | The dashboard’s security audit item adds --geiger only when cargo geiger --version succeeds, so it no longer fails on a machine without cargo-geiger. Run cargo rullst audit --geiger to require the dependency inventory. |
| Core deferrable jobs | New in 13.0, opt-in: Queue::dispatch_deferred and dispatch_deferred_with place a job by a deadline, daily TimeWindows and an optional application-provided CarbonIntensitySource (deterministic FixedIntensitySource for tests; no network source in Core), and Scheduler::deferrable_task defers recurring runs into windows. Placement reuses dispatch_at, so SQLite/Redis need no schema change and 12.x rows keep working; custom drivers without durable scheduling reject future placements with QueueError::Unsupported. Existing dispatch, dispatch_at and Scheduler::task are unchanged. See deferrable jobs. |
| Core scheduler time zones | New in 13.0.0-alpha.2, opt-in with the scheduler-tz feature: Scheduler::task_in_tz(expression, "America/Sao_Paulo", handler) matches the cron fields against that zone’s wall clock, so a task written in UTC to stand for a local time (0 10 * * * for 07:00 in São Paulo) can state the local time instead. Scheduler::task is unchanged and stays in UTC. 13.0.0-alpha.3 adds Scheduler::deferrable_task_in_tz, the zoned form of deferrable_task, whose windows are wall times of the zone. SchedulerError gains InvalidTimeZone; matches are unaffected because the enum is #[non_exhaustive]. See time zones for scheduled tasks. |
| Dash N+1 warning | New in 13.0, opt-in by running cargo rullst dash: the development telemetry’s observed database object gains repeated_threshold, repeated_queries_total and recent_repeated (ORM operation fingerprints a single request repeated at least three times, by matched route), and the dash shows a POSSIBLE N+1 QUERIES panel. The rullst.dev-telemetry.v1 schema stays compatible: older dashboards ignore the fields, and a newer dashboard shows “N+1 detection needs request-correlated ORM telemetry” for an older application. Studio’s N_PLUS_ONE_THRESHOLD now re-exports rullst_core::query_patterns::N_PLUS_ONE_THRESHOLD (still 3). See N+1 query warning. |
| CLI security report | New in 13.0: cargo rullst audit --report (md by default, or html or json, with optional --output <path>) writes SECURITY_REPORT.<format>, an evidence report mapped to OWASP ASVS 5.0.0 Level 1 with a personal-data inventory, accessibility heuristics and a NOT EVALUATED list, and exits 1 when a check reports FINDINGS or ERROR. No change for existing commands: audit without --report keeps its checks, output, rullst.cli-audit.v1 JSON and exit status. See the security report guide. |
| Generated security tests | New projects get src/security_tests.rs, declared under #[cfg(test)] (all(test, debug_assertions) for Nexus starters) in src/main.rs, which now builds its router in fn router() that main passes to Server::new. cargo test runs the checks offline against that router wrapped in the staging/production baseline: security headers, CSRF, WAF, plus the SaaS/LMS sign-in rate limit, the LMS lesson owner check and the Blank JSON API bearer token. Existing projects can copy the file from a new project and extract their router the same way; nothing changes at runtime. |
| Blank JSON API starter | The --api starter adds POST /api/messages, registered with Server::with_machine_endpoints as a bearer machine endpoint, and a random API_TOKEN in .env (empty in .env.example). JSON clients send Authorization: Bearer <API_TOKEN>; requests with cookies are refused, and startup fails when API_TOKEN is unset or weaker than 32 characters. Previously the starter had no write route, and the first one added was refused with 403 for JSON clients in staging and production because the baseline requires CSRF. Existing API projects keep their behavior; to adopt it, register each JSON write route as shown in machine_endpoints(). |
| CLI audit lockfile | cargo rullst audit, audit --report and audit --sbom in a workspace member, which has no Cargo.lock, now use the workspace root’s lockfile found through cargo metadata (passed to cargo audit as --file) instead of failing the dependency check. The SBOM then lists every package of that lockfile. |
| RASP shell signatures | RaspSecurityLayer and RaspInspector match | sh only when sh ends at a word boundary, and ; cat only when a path-like argument follows (a path, dotfile, ~, $, option or glob). Prose such as “Add it to the cart | shopping list” or “dogs; cat food” is no longer refused with 403; ; cat /etc/passwd still is, while ; cat followed by a bare word no longer is. |
| CLI footprint report | New in 13.0.0-alpha.1, renamed cargo rullst green in 13.0.0-alpha.4 (see the CLI ai removed, footprint renamed green row): cargo rullst footprint builds the release binary (or measures a loopback --url), runs a bounded closed-loop load and reports requests/s, latency p50/p95/p99, errors, process CPU time, peak and idle RSS, binary and Docker image size, energy from readable RAPL counters or a labelled --cpu-watts estimate, and an SCI figure (ISO/IEC 21031:2024) only from a user-provided --grid-intensity; --json prints rullst.cli-footprint.v1. Unavailable values are NOT MEASURED with the reason. No change for existing commands. See the green guide. |
CLI dev/dash shutdown | SIGTERM and SIGHUP (and closing the console on Windows) now stop dev and dash like Ctrl+C: the supervised application and its process group are stopped and the rullst-dev-* snapshot removed. Previously the CLI died on the default signal action, leaving the application holding its port. Scripts that relied on the application outliving a terminated CLI must start it separately. |
CLI dev --ts-sync | The flag now regenerates rullst-client.ts after the initial build and after every successful rebuild, and reports a failed generation in the dev log. It previously generated the SDK once before startup, ignored errors, and never refreshed it. |
CLI dash live metrics | dash polls the application’s loopback GET /_rullst/dev-telemetry once a second and shows requests/s, latency (p50/p95 and a sparkline), errors, recent requests and, when the application reports them, ORM query and queue figures. An application that does not serve the endpoint shows a “telemetry not available” panel with the steps to enable it instead of numbers, and figures from a process the dashboard did not start (another application on the same port) are never shown. r now restarts the application from the current build (refused while a migration or a rebuild runs) and ? opens a help overlay that Esc closes before it quits the dashboard. The footer lists r and ? and shows d and Tab only on terminals at least 120 columns wide. Both log panes now keep the newest line visible when earlier entries wrap; previously the newest rows could fall below the fold. cargo rullst dev prints one extra stderr line at startup suggesting cargo rullst dash; scripts that compare its exact output must allow it. See the CLI reference. |
| Core development telemetry | In a debug build running in Development under cargo rullst dev/dash (RULLST_DEV_GENERATION set), Server also mounts GET /_rullst/dev-telemetry for loopback peers with a loopback Host (HTTP/1.0 requests and requests with proxy forwarding headers receive 404), records each request’s method, path (no query string), status and duration in a bounded in-process recorder from an outermost layer (so panics answered by the development error console and the responses of the security, lifecycle and traffic layers are counted; development polls and /static files are not), and keeps that poll out of the access log, rate limiter and Traffic Shield. An application route at that path conflicts with it, as with the other /_rullst/* development routes. In debug builds telemetry::init_telemetry (called by Server::run) adds a passive layer that counts rullst.orm.query spans (a chunk/chunk_by_id traversal counts its page fetches and its handler’s operations, not itself); it records nothing unless the endpoint is mounted. The new Server::with_dev_queue(queue) reports a queue’s pending count to the dashboard. Release builds, Staging and Production are unchanged. See the telemetry guide. |
| Core development error console | The development panic page (debug build, Development, loopback peer) replaces its AI panel with an “Ask your AI assistant” panel: a read-only prompt to copy into your own assistant (error message, location, the first project stack frames, request method and path) and, when the error is recorded, the path of its JSON context; the page no longer calls /_rullst/explain or /_rullst/autofix and makes no network request. Within Server’s development stack the console records each rendered panic (message, location, project backtrace frames, request method and path without query string; never headers, cookies or bodies) under a random 128-bit id, at most 32 entries for 30 minutes, in process memory, and Server mounts GET /_rullst/errors/{id} with the console, answered only to direct loopback requests with a loopback Host and no proxy forwarding headers (others receive 404). POST /_rullst/autofix (handle_autofix, still exported) no longer validates a path or returns an AI-offline message: loopback peers receive 410 Gone with success: false, a message that points to that panel and context_url: "/_rullst/errors/<error-id>", other peers 403. An application route at /_rullst/errors/{id} conflicts with it, as with the other /_rullst/* development routes. The hot-reload server shows the panel without a context path. Release builds, Staging and Production are unchanged. |
CLI ai fix and ai review | Added by an earlier 13.0 prerelease and removed with cargo rullst ai in 13.0.0-alpha.4 (see the CLI ai removed, footprint renamed green row): cargo rullst ai fix <error-id> and cargo rullst ai review no longer exist. No 12.x command changes. |
| CLI project updates on SELinux | update project review and apply no longer reject a manifest or staged file whose only extended attribute is the SELinux label (security.selinux), so they work on Fedora/RHEL hosts; the replacement receives the label the policy assigns to a new file in that directory. ACLs, file capabilities and other attributes still require manual handling. |
CLI pkg in a virtual workspace | In a Cargo.toml with [workspace] but no [package], pkg add writes the dependency to [workspace.dependencies] instead of a top-level [dependencies] table that made every later Cargo command fail, and pkg list lists that table. Reference the entry from a member with { workspace = true }. |
CLI new repository | Like cargo new, cargo rullst new now runs git init in the new project when Git is installed and the destination is not already inside a Git work tree, and writes a .gitignore keeping .env and /target out when none exists. Pass --vcs none to skip it, for example when a script copies the generated folder into another repository. Without Git it warns and keeps the project. |
CLI generated validator dependency | Generated manifests declare validator = { version = "0.21", features = ["derive"] }, because #[derive(rullst::Validate)] expands to ::validator paths. In a project generated earlier, add that line before deriving Validate on a ValidatedForm/ValidatedJson DTO. |
CLI make:model imports | SQLx models now start with use rullst::db::{FromRow, Orm}; instead of also importing the unused RullstModel and sqlx, so a new model compiles under -D warnings. Existing model files are not touched. |
CLI dockerize and generate:buildah | The binary path in the Dockerfile and the Buildah image name come from [package].name parsed as TOML, as in deploy, instead of the first line starting with name = . A [lib] or [[bin]] table before [package], name="x" without spaces, single quotes or a trailing comment no longer produce a wrong name. Regenerate files written from such manifests. |
| CLI errors and exit codes | Failures are printed on standard error by one report (error: <title> with What happened, How to fix and Docs) instead of Error: <message>; the original message stays in What happened, with passwords, secret-like values and terminal control characters masked. Match the message text or error: instead of Error: . Project commands run outside a project root (make:model, make:controller, make:resource, make:middleware, make:worker, make:migration, make:migration:auto, make:cors, make:jwt, make:mfa, auth, db:*, studio, build, generate:openapi, generate:ts) report this on standard error and exit 1 instead of printing to standard output and calling process::exit(1); the public generators::* functions behind them now return an Err instead of terminating the calling process. An unknown command prints the closest commands and exits 2 (clap’s usage status) with a new message. A command run from the home menu or the command palette that fails is reported once, by the command itself, and the CLI exits with its status; previously a second dashboard command ... failed error followed and the exit status was always 1. A panic prints a short report unless RUST_BACKTRACE or the new global -v/--verbose flag is set. An argument that is not valid UTF-8 is a usage error (exit 2) instead of a panic (exit 101). When standard output is not a terminal, or with NO_COLOR, CI or TERM=dumb, generator output is now plain text without ANSI colours. See errors and exit codes. |
CLI doctor | cargo rullst doctor prints grouped ✓/!/✗/· checks with a fix and docs link per problem instead of [OK]/[WARNING]/[NOT FOUND] lines and N checks passed, M warnings, and adds project, configuration, database reachability, migration, security-baseline and disk-space checks. It now exits 1 when a check fails (a missing or pre-1.96 rustc, missing Cargo, an unreachable configured database, an unknown RULLST_ENV, an invalid Rullst.toml, a Git-tracked .env, less than 1 GiB free); it previously always exited 0, and the public generators::doctor::run_doctor now returns an Err in that case. Missing optional tools (cargo-deny, cargo-geiger, cargo-mutants, Kani, cargo-llvm-cov, Docker) are informational; Kani is detected by its cargo-kani executable without running it. --fix still runs rustup component add rustfmt clippy; its result reads installed by --fix. --json prints rullst.cli-doctor.v1. See doctor. |
| CLI output additions | Successful generators end with a Next steps block of one to three commands on standard output (none for new, auth, foundry:init, make:k8s or --json). New completions <shell> and info [--json] (alias version) commands, and --json for inspect routes (rullst.cli-routes.v1) and audit (rullst.cli-audit.v1, progress on standard error). Both home menus gain Search All Commands before View Help & Commands, so the entries after it move down by one. inspect routes no longer appends the route call’s closing parenthesis to the handler (controllers::users::show instead of controllers::users::show)). |
| SaaS plan gates | The generated /reports/billing route uses authenticated identity, fresh revision-fenced Stripe reconciliation and an explicit BILLING_REPORT_PLAN_IDS allowlist. Production requires live state; offline fixtures cannot grant access. The additive rullst-capital::entitlements API supports other trusted adapters. Existing applications must review/install the generated module and route; no new billing schema is needed. |
| Capital providers and NFS-e removed | rullst-capital keeps the provider-neutral contracts (BillingProvider, MeteredBillingProvider, checkout, subscription, entitlement, quota, charge, invoice and webhook verification/replay APIs), StripeProvider and the experimental InfinitePayProvider, whose live checkout and callbacks still fail closed. Removed: PaddleProvider with its Paddle* customer, checkout, subscription and portal types; LemonSqueezyProvider with LemonSqueezyUsageRecord and LemonSqueezyUsageAction; PolarProvider with PolarCheckoutRequest and PolarCheckoutSession; RazorpayProvider; MercadoPagoProvider; AlipayProvider; CoinbaseCommerceProvider/CoinbaseProvider; PicPayProvider; the Wise payout adapter (WiseProvider, WiseTransferState, WiseTransferStateChange); and the payout contracts (PayoutProvider, PayoutStatus, PayoutEvent, init_payout_provider, try_init_payout_provider, payout_provider). The NFS-e module is removed too: rullst_capital::fiscal and its crate-root re-exports, the nfse feature, the facade’s capital-nfse feature, Invoice::to_dps and CapitalError::FiscalError. It was never validated with a real municipality and may return as a separate product outside Rullst. These adapters stay in the 12.x line and in Git history. To keep another gateway, implement BillingProvider in application code as described in Writing your own payment provider. make:billing and the SaaS starter now generate Stripe only: BILLING_PROVIDER must be stripe, and the Paddle modules, the Lemon Squeezy fixture and the BILLING_STORE_ID and BILLING_PADDLE_* settings are gone; existing generated controllers are not rewritten. In one generated by 12.x (the SaaS starter or make:billing), delete the lemonsqueezy branches, lemon_provider and BILLING_STORE_ID, and keep BILLING_PROVIDER=stripe; the Stripe paths compile against v13 unchanged. make:mail-invoice now generates a receipt-only PaymentReceiptEmail that enables only mailer; delete from_nfse_response and the NFS-e variants from a mailable generated earlier. |
| Capital quota keys on MySQL/MariaDB | SqlQuotaStore keys (subject kind and ID, feature, event key) are case-sensitive on every backend. New MySQL/MariaDB tables use ascii_bin key columns. A quota table created by an earlier release, whose key columns fold case, makes prepare_schema and every reserve, release and usage call return QuotaError::StorageUnavailable until you run the ALTER TABLE migration in the Capital README. Rows that earlier releases merged for keys differing only by case stay merged. PostgreSQL and SQLite tables are unchanged. |
| Capital zero tier limit | Billable::quota_request for a feature whose tier_limit is Some(0) now returns QuotaError::LimitExceeded { used: 0, requested, limit: 0 } instead of QuotaError::InvalidRequest, matching check_quota. Map it like any other quota denial (for example 402/403 with an upgrade prompt) rather than a 400/500. |
| Capital provider subscription IDs | Subscription IDs made only of dots (., ..) now fail with CapitalError::SubscriptionError before any provider request. A live adapter put them into the URL path, where they are dot segments that send the authenticated request to another provider endpoint. |
| Capital invoice amounts | Invoice::validate, total_minor, try_generate_html, the PDF renderers and bind_succeeded_charge now reject an item amount or total that rounds to zero minor units (for example 0.0000000005 USD or 0.0000001 JPY) with CapitalError::InvalidInvoice, as the error message already stated. Such an item used to pass as 0 minor units. Amounts of at least one minor unit are unchanged. |
| Messaging outbox relay key | OrmOutboxRelay publishes with the broker idempotency key <lowercase hex SHA-256 of the stream>/<event_key> instead of the bare event_key, so streams that share a topic no longer deduplicate (identical content) or conflict (different content) with each other. A claim published before the upgrade and not acknowledged is republished once under the new key, producing a second broker message; drain or acknowledge in-flight outbox claims before upgrading, or rely on consumer idempotency. |
| Messaging encrypted SQLite startup | SqliteBroker::connect_encrypted now opens one retained record per referenced storage key ID. A prior key with the right ID but wrong bytes, or a tampered record, fails at startup with MessagingError::StorageAuthenticationFailed (or CorruptStorage for damaged row metadata) instead of starting and later failing every receive whose oldest pending record uses it. Correct keyrings are unaffected. |
| Messaging Redis Streams candidate | RedisBroker stores a new state format (v2) so that messages available at the same time, such as every retained message of a new earliest group, are claimed in publication order instead of text order (sequence 10 before 9). A namespace provisioned by an earlier v13 development build fails with MessagingError::ConfigurationConflict; drain it with that build, then provision a new namespace or generation. No 12.x release contains this adapter. |
| Mail providers removed | rullst-mail keeps Resend (ResendDriver), AWS SES (AwsSesDriver), SendPulse (SendPulseDriver) and SMTP (SmtpDriver), the development drivers (LogDriver, MemoryDriver and the offline mock_* fixtures) and the shared failover, tenant resolver, pipeline, attachment inspection, suppression, observation, feedback and queue support. Removed: SendGridDriver, PostmarkDriver, MailjetDriver, MailtrapDriver and the Azure Communication Services transport (AzureCommunicationDriver, AzureMailCredential, AzureMailAccessToken, AzureManagedIdentity, StaticAzureMailCredential), their MAIL_DRIVER values sendgrid, postmark, mailjet, mailjet-sandbox, mailtrap, mailtrap-sandbox and azure-acs, and the settings only they read (SENDGRID_API_KEY, POSTMARK_SERVER_TOKEN, POSTMARK_API_KEY, POSTMARK_MESSAGE_STREAM, MAILJET_API_KEY, MAILJET_SECRET_KEY, MAILTRAP_API_TOKEN, MAILTRAP_SANDBOX_ID, AZURE_COMMUNICATION_EMAIL_ENDPOINT, and the managed-identity IDENTITY_ENDPOINT, IDENTITY_HEADER and AZURE_CLIENT_ID). Cargo features are unchanged. A MAIL_DRIVER or [mail] driver that names a removed driver now fails whenever the Mail facade resolves its driver (each send and each queued job) with MailError::ConfigError naming the driver and this row, whatever credentials are set; it never falls back to log, memory or an offline mock. Queued rullst_mail_send jobs written by 12.x name no provider: a 13 worker delivers them through the driver it is configured with, and a worker still set to a removed driver fails each job with that error, so the job stays failed until Queue::retry_failed_job after the driver is changed. Suppression rows and delivery observations keep their provider labels (for example sendgrid) and stay readable. Select a kept driver, or implement MailDriver in application code for another provider and install it with Mail::set_driver or TenantMailResolver, as described in Writing your own mail transport. These adapters stay in the 12.x line and in Git history. |
| Mail driver default | With neither MAIL_DRIVER nor [mail] driver set, the Mail facade no longer falls back to the metadata-only log driver in staging or production (RULLST_ENV or APP_ENV from the environment or .env, or [app] env, as Server resolves them); it returns MailError::ConfigError. Configure the real driver, or set MAIL_DRIVER=log explicitly. Development and test keep the fallback. |
| Mail sender | Resend, SES and SMTP no longer substitute [email protected] when a message has no from. Configure a default sender your provider account has verified with MAIL_FROM (or from in the [mail] section of Rullst.toml), for example MAIL_FROM="Acme <[email protected]>": Mail facade sends, including generated mailables, use it for messages without a from, while an explicit from still wins. With neither, real delivery returns MailError::ConfigError before any request, as the 12.1 REST transports already did. New projects list an empty MAIL_FROM and a commented MAIL_DRIVER in .env and .env.example; the facade now reads its MAIL_* and provider settings from .env after the process environment, like DATABASE_URL. Offline mock delivery is unchanged. |
| Mail attachment inspection | LocalAttachmentInspector classifies markup by its root element and namespaces, as browsers do, so XML whose root is a prefixed svg/html, follows a DOCTYPE or processing instruction, binds the SVG or XHTML namespace (also through character references) or declares DTD entities is now rejected as SVG (every policy) or active markup (strict()); UTF-16 markup is read by its byte-order mark. Review XML attachments that embed these namespaces or entity declarations. |
| Mail link checks | The pre-flight homograph check now resolves each link as a browser does (WHATWG URL parsing through the url crate): tabs and newlines are removed, \ counts as / for HTTP(S), https:host, https:\\host and scheme-relative \\host name a host, and percent-encoded or A-label (xn--) hosts are inspected in Unicode form. Messages whose links spell a mixed-script host this way, which were previously sent, now fail with MailError::SendError. Click tracking checks each destination as one link and no longer wraps one that contains whitespace or control characters, so the pipeline inspects (and may reject) the original href. |
| Mail facade settings | A MAIL_PORT that is not an integer from 1 to 65535 (for example 587000 or smtp) now makes MAIL_DRIVER=smtp facade delivery fail with MailError::ConfigError instead of silently connecting to port 25. An unset or empty MAIL_PORT still means 25. The facade now reuses the driver built from its settings across messages and queue jobs until a setting changes (or Mail::reset_driver() runs), so connection pools and the native SES client are shared; MAIL_DRIVER=memory is still rebuilt per message. |
| Mail queued attachments | Queued mail (Mail::send after Mail::init_queue, Mail::enqueue and Mail::enqueue_for_tenant) stores each attachment’s bytes in the rullst_mail_send job as one standard base64 string instead of a JSON array with one number per byte. A 13 worker (register_mail_handler) still reads jobs written by 12.x, but a 12.x worker fails every job with an attachment written by 13. In a rolling deployment, upgrade every process that runs the mail worker before any process that enqueues mail; once 13 workers run, retry jobs that a 12.x worker failed with Queue::retry_failed_job. Before rolling back to 12.x, stop 13 producers and let 13 workers drain the queue. Jobs without attachments are unchanged. |
| Mail SES addresses | Native and bearer-proxy SES requests now send 7-bit ASCII addresses, as SES requires: internationalized domains as IDNA A-labels and non-ASCII sender display names as RFC 2047 encoded-words, instead of verbatim Unicode that SES rejected with a permanent BadRequest. A non-ASCII local part now fails before the request with MailError::ValidationError. A trusted proxy receives the encoded FromEmailAddress and ToAddresses. |
| Mail Resend scheduling | A direct (non-queued) ResendDriver send scheduled more than 30 days ahead, Resend’s documented limit, now fails before the request with MailError::ConfigError instead of a permanent provider rejection. Schedule such mail through a durable Rullst queue (Mail::enqueue). The offline Resend fixture still accepts it. |
| Mail tracking recipient | Message::try_with_open_tracking/try_with_click_tracking (and their deprecated variants) now sign the bare recipient address, so OpenEvent.email and ClickEvent.email are [email protected] rather than Alice <[email protected]>. Handlers that parsed the display-name form can compare the address directly. |
| Mail plain-text fallback | The text part that Message::html derives when no text is set (and strip_html_to_plain_text) now appends each absolute HTTP(S) link target after its label as label <URL>, unless the label already is the URL, so text-only readers keep call-to-action links. Tests that compared the whole derived text of linked HTML need the new form; set text explicitly to control it. |
| Labs runner | The unpublished rullst-labs-runner candidate (Linux Rust-to-Wasm/Wasmi executor) was removed from the v13 workspace and is not published; its source remains in git history. rullst-labs APIs are unchanged and never execute learner code. Applications that evaluated the candidate deploy their own isolated runner against the controller contract; the non-executing byo_runner_controller example shows the controller flow. |
| Education candidates | These v13 crates changed during source evaluation, before their first publication in 13.0.0-alpha.1. rullst-supervision returns Capacity for an observation once one learner (tenant and subject) holds 4096 unexpired events across all sessions, or the store-wide event limit when lower; ended sessions keep their events until event retention ends. The new Limits::subject_events selects another bound. The rullst-media upload module accepts a grant expiring up to 3600 seconds plus 300 seconds of clock skew after the browser clock, instead of refusing a maximum-length grant when the browser clock is behind the server. Its SQLite store returns MediaError::Busy instead of Storage when a lock or connection wait exceeds 3 seconds; retry it with backoff rather than treating it as an integrity incident. rullst-labs adds the optional StoreConfig::tenant_exercises quota for stores shared by several tenants; without it, max_exercises stays a single store-wide budget. |
| Labs package | New in 13.0: rullst-labs is in the release inventory and was first published in 13.0.0-alpha.1 (previously publish = false). Depend on it directly; it has no umbrella feature and no default features. It packages exercise, authorization, job and grading contracts only and never executes learner code; applications still deploy their own runner against the controller contract. No API changed. |
| Media S3 storage | New in 13.0, opt-in: the rullst-media s3 feature adds s3::S3Storage for AWS S3, Cloudflare R2 and MinIO. It stores and serves the original object without transcoding: upload grants come from the new MediaService::upload_declared with an UploadDeclaration (presigned PUT), playback uses the new PlaybackKind::Original (presigned GET), and grants expire within 900 seconds. UploadGrant gains protocol (Bunny grants serialize "protocol":"Tus"), content_type and content_length; VideoProvider gains upload_declared, whose default returns Unsupported. Code that matches PlaybackKind exhaustively needs an Original arm; Bunny returns Unsupported for it. Experimental; validated against the offline mock only; provider interoperability not yet validated. See S3-compatible object storage. |
| Generated billing settings | New make:billing/SaaS controllers read every BILLING_* key with rullst::config::project_setting (process environment, then .env) instead of the process environment only, and an unreadable or malformed .env makes billing unavailable. Their production check now uses RullstConfig::global().environment(), so RULLST_ENV/APP_ENV from the .env a started Server read and [app].env count, and an invalid value is treated as production. Existing generated controllers are not rewritten; apply the same change by hand to read .env values. |
| Application templates | SaaS and make:billing add the report module; privacy generators remain explicit additions. Updating a dependency does not replace generated controllers or application customizations. |
| Starter migrations | New Blog, ERP and Portfolio seeds no longer call SQLite-only datetime('now'); seeds with explicit ids advance their PostgreSQL sequences; and SaaS, LMS, cargo rullst auth and SQLx make:billing create their indexed email, status, event-key and subscription-id columns as VARCHAR, with the LMS audit index ordered by id, so these migrations also run on PostgreSQL, MySQL and MariaDB. Already-applied migrations are not re-run: an existing PostgreSQL deployment that seeded rows should run SELECT setval(pg_get_serial_sequence('<table>', 'id'), (SELECT MAX(id) FROM <table>)) per seeded table. |
| ERP starter access | The dashboard at / now sits behind the same NexusAuthPolicy as Nexus and the inventory forms (loopback-only in debug, Basic Auth over verified TLS in release), because it lists customers and revenue. In existing ERP applications, move get("/" => …index) into the routes passed to admin_access.protect_router. |
| LMS lesson progress | The player renders a fresh random idempotency key and the controller scopes it to the requested percentage, so later saves no longer return 409. Keys are unique per learner (the replay lookup and the unique index cover (subject_user_id, event_key)), so one learner can no longer claim another learner’s key and block their saves. Existing LMS applications keep the fixed progress:{user}:{lesson}:next key until they copy the new new_progress_key/progress_event_key helpers; to scope keys per learner, add subject_user_id to the replay query and replace lesson_progress_events_key_unique with a unique index on (subject_user_id, event_key). |
| LMS concurrent progress saves | record_progress now claims the idempotency key by inserting the progress event as the transaction’s first write (its previous_percent comes from a subquery) and treats a unique violation as a replay, so a double-submitted save returns the stored receipt instead of 503. Existing applications must copy the service’s replay helper and record_progress. |
| LMS lesson media in Nexus | The Lesson Nexus fields media_url and captions_url are FieldKind::Text instead of Url, so lessons with the seeded same-origin /static/media/*.vtt captions can be edited again (the player still accepts only HTTPS or root-relative sources), and media_kind is an Enum of video/audio. A lesson the player cannot render now returns a logged 500 instead of a silent 503. Existing applications can make the same changes in src/models/lesson.rs and the learning controller. |
| Blog robots and sitemap | The Blog starter’s robots.txt and sitemap.xml use absolute URLs from RULLST_PUBLIC_ORIGIN (an HTTPS origin without a path, read from the process environment, then .env); the sitemap lists the home page and the 1,000 newest posts. Unset, robots.txt has no Sitemap: line and the sitemap is empty instead of advertising the relative / and /sitemap.xml that crawlers reject. New Blog projects list the variable in .env/.env.example. Existing applications must copy the controller’s public_origin, path_segment, robots_txt and sitemap_xml. |
| Generated login and registration | SaaS, LMS and cargo rullst auth controllers run Argon2id through at most four bounded permits (503 after a two-second wait) and export credential_rate_limit (ten submissions per peer per minute, then 429), which the SaaS/LMS routers apply to POST /login and /register. Behind a reverse proxy, configure [security] trusted_proxies. Existing applications must copy the controller and route changes. |
| SaaS user JSON | The SaaS starter’s User.password_hash is now #[orm(hidden)], as in the LMS starter, so to_json(), audit rows and post-commit Redis model events no longer carry the Argon2 hash. In an existing SaaS application, add #[orm(hidden)] to that field. |
| Registration timestamps | SaaS, LMS and cargo rullst auth registration now stores the current UTC time in created_at/updated_at; the generated User model reads them as chrono::DateTime<Utc> (see ORM date/time columns), and utc_timestamp(), User::change_password and password_reset::redeem take that type instead of YYYY-MM-DD HH:MM:SS text. Registration previously stored empty strings, which replaced the column default because the ORM inserts every field. Existing applications can copy utc_timestamp from a new controller; rows created earlier keep their empty timestamps until updated. |
| Billing row timestamps | SaaS and make:billing controllers now store the current UTC time in created_at/updated_at of the billing customer they create on a first checkout and of a new subscription (SQLx), instead of empty strings that replaced the column default because the ORM inserts every field. SQLx billing models read those columns as chrono::DateTime<Utc>, and the billing stores write CURRENT_TIMESTAMP in the column’s own type instead of casting it to text; Turso billing keeps text. Existing applications can copy utc_timestamp from a new controller. |
| Registration lengths | SaaS, LMS and cargo rullst auth registration count the 120-character name limit and the 12-character password minimum in UTF-16 code units, as the forms’ minlength/maxlength do, instead of UTF-8 bytes. The password maximum stays at the 72 bytes that rullst::auth::hash_password accepts, and the error message now says so. Long non-ASCII names are no longer rejected, while a non-ASCII password shorter than 12 characters (for example six Cyrillic letters, 12 bytes) is now refused. Existing sign-ins are unaffected. Existing applications can copy form_length and valid_password. |
| Revocable login sessions | Additive: rullst-auth adds make_login_cookie_with_epoch(user_id, epoch, max_age), encrypt_session_with_epoch, decrypt_session_claims, SessionClaims (verify_epoch), DEFAULT_SESSION_MAX_AGE, MAX_SESSION_MAX_AGE and AuthError::SessionRevoked. make_login_cookie cookies keep their format, 30-day lifetime and validity; decrypt_session rejects the new v2. tokens. New SaaS, LMS and cargo rullst auth projects store users.session_epoch (BIGINT NOT NULL DEFAULT 0, #[orm(db_default)] on the model), issue epoch cookies, refuse a cookie with another epoch in auth_middleware (the cargo rullst auth middleware now loads the user on every request) and mount POST /logout/everywhere, which increments the epoch. To adopt in an existing application, add the column with a migration and the #[orm(db_default)] pub session_epoch: i64 field, issue cookies with make_login_cookie_with_epoch, decode them with decrypt_session_claims and verify_epoch, and increment the column in one UPDATE for sign-out everywhere, password changes and resets, and deactivation. Existing cookies decode as epoch 0 and keep working until the first increment. make:age-gate and make:privacy accept only authentication files equal to this release’s SaaS starter, so a SaaS application generated by 13.0.0-alpha.1 must adopt the new auth_middleware.rs and auth_controller.rs first. See encrypted sessions. |
| Password change and reset | Additive: rullst-auth adds PasswordResetToken (generate, expose, digest), password_reset_token_digest and password_reset_digests_match. New SaaS, LMS and cargo rullst auth projects get src/controllers/password_controller.rs (/password/change behind auth_middleware, /password/forgot and /password/reset, every submission behind CSRF and credential_rate_limit), src/models/password_reset.rs, a password_reset_tokens migration, User::change_password, the forgot_password_page, reset_password_page and change_password_page views, a notice argument on login_page (shown on /login?reset=done) and the mail feature; the SaaS and LMS .env list RULLST_PUBLIC_ORIGIN. A change keeps only the asking browser signed in; a reset signs out every device and signs nobody in. To add the flows to an existing application that already uses session epochs, copy those three files and the migration (named after your users migration) from a new project of the same starter, add User::change_password to the user model, the three views and the login notice to src/pages/auth.rs, register the modules and routes as the new router does, make the auth_controller helpers that password_controller imports pub(crate), enable the mail feature, and set RULLST_PUBLIC_ORIGIN, MAIL_DRIVER and MAIL_FROM for staging and production. See Tutorial 11. |
| VPS deploy proxy | deploy --platform vps now attaches both services to an edge compose network pinned to 172.31.250.0/24, gives Caddy 172.31.250.10, and adds that address to [security] trusted_proxies in Rullst.toml (creating it if needed) before a missing Dockerfile is scaffolded. Clients behind Caddy no longer share one rate-limit bucket, such as the starters’ login/registration budget. Existing docker-compose.prod.yml files are kept: add the network and address yourself, or move the file aside and rerun the command. An existing trusted_proxies list is not changed. Fly.io, Railway and Render print a notice while no trusted proxy is configured. |
| Blank database status | The Blank starter’s public home page (HTML and JSON API, SQLx and Turso-primary) probes the database with a one-row User::query().first() instead of User::all(), which read up to 1,000 SQLx or 10,000 Turso rows per request. Existing applications can make the same one-line change. |
| Portfolio profile | The Portfolio page shows the first profile by id instead of Profile::find(1), returns 404 when none exists instead of a hard-coded placeholder person with 200, and returns a logged 503 when a profile, project, experience or skill query fails instead of empty sections. Repository-mode ProfileRepository::get now returns Result<Option<Profile>, rullst_orm::Error> and the other repositories’ all return Result<Vec<_>, _>. The seeded profile’s e-mail is [email protected]. Existing applications must copy the controller (and repositories) to adopt this. |
| Blog and Portfolio pages | index_page, detail_page and the Portfolio render take a csp_nonce argument from the request’s CspNonce; style attributes became classes, the pages use system fonts and the default avatar is /static/rullst.png, so styling survives the production CSP. Remote avatars need a same-origin copy or a reviewed img-src change. |
| Generated linker configuration | .cargo/config.toml (mold/lld selection of the generating host) is now listed in the generated .gitignore and .dockerignore. In an existing project, add .cargo/config.toml to both files or delete its -fuse-ld flags before building in CI or Docker. |
| Build files from the 12.0 CLI | Only projects generated by the 12.0 CLI; 12.1 generators no longer write these. .cargo/config.toml passes the unsupported MSVC linker flag /DEBUG:FASTLINK: delete that [target.x86_64-pc-windows-msvc] block. .gitignore lists /Cargo.lock: remove the line and commit the lockfile, so builds and deployments use the versions you tested. A Dockerfile written by --docker or dockerize runs cargo build --release: add --locked once the lockfile is committed. See Projects generated by the 12.0 CLI. |
| ERP orders and stock | store_order reserves stock with a conditional UPDATE ... WHERE stock >= ? and inserts the order in the same transaction; add_stock increments in SQL. Quantities outside 1–10,000, prices outside 0–1,000,000, stock outside 0–1,000,000 and names, SKUs or customer names over 255 characters (UTF-16 code units, as the forms’ maxlength counts them) return 422, an unknown product 404, insufficient stock 409 and database errors 503, instead of redirecting or rendering 0 Units. Existing ERP applications keep the read-modify-write handlers until they copy the new controller. |
| Blog and ERP reads | Blog index pages through posts newest first (?page=N, 20 per page) and show queries by slug; ERP index pages through products (?page=N, 50 per page), lists the 20 newest orders and computes revenue, order count and low-stock alerts in SQL. Neither depends on Model::all(), which stops at the ORM’s 1,000-row cap without an ORDER BY. Database errors now return 503 instead of an empty page or a 404. blog::index_page takes a PaginationResult<Post> and erp::dashboard_page a PaginationResult<Product> plus a DashboardSummary; existing applications keep their controllers until they copy the new ones. |
| Blog and ERP page bounds | The Blog index and the ERP dashboard serve ?page= 1–100,000 and answer a larger page with 404 instead of passing it to paginate, whose overflowing or out-of-range OFFSET was reported as a 503 database failure. Existing applications can add the same MAX_PAGE check. |
| Docker build context | The .dockerignore written by --docker/dockerize now also excludes .env.* (keeping .env.example), Foundry.toml, *.sqlite, *.sqlite3, *.duckdb and their journal files, so the development database and secret files no longer reach the builder stage, a remote builder or its cache. An existing .dockerignore is not rewritten: add these patterns to it. |
| Kubernetes Ingress | make:k8s now emits ingressClassName: nginx instead of the deprecated kubernetes.io/ingress.class annotation and a spec.tls section (<project>-tls Secret) for the placeholder host, so cert-manager issues a certificate. Previously generated Ingresses served the production app over plain HTTP; add a tls block for the real host to them. |
| Starter health probes | Every starter now merges rullst::health::health_router(), so the /health and /ready probes written by make:k8s, deploy (Fly, Railway, Render) and foundry:deploy return 200 instead of 404, which kept Kubernetes pods unready, restarted them and failed Fly releases. Existing applications should add .merge_axum(rullst::health::health_router()) (or the lifecycle-aware health_router_with_lifecycle) outside any authentication layer. |
| Kubernetes and Buildah names | make:k8s and the --buildah/generate:buildah script derive their object, container, Service and image names from the package name lowercased with each run of other characters replaced by - (at most 55 characters), so my_startup or MyApp no longer produce manifests and tags that Kubernetes and OCI registries reject. Manifests or scripts generated for a lowercase hyphenated name are unchanged; for other names, regenerate them or rename the objects and image. |
| Pre-compressed static assets | cargo rullst dev deletes static/**/*.br and *.zst siblings of html, css, js, json, svg, wasm, xml and txt assets when they are not newer than their source, at startup and before each rebuild, and no longer restarts the application when only such a sibling changes. Siblings without a source file are kept. Rerun cargo rullst build to regenerate them; outside dev, rerun it after editing assets so stale siblings are not shipped. |
| Static asset caching | Files served by the standard /static mount no longer fall back to the production baseline’s Cache-Control: no-store. 2xx and 304 responses for a content-hashed name (a ./--separated segment of 8–64 lowercase hex characters with a digit and a letter, such as app.3f9a2c1b.css) get public, max-age=31536000, immutable; other static files get no-cache and are revalidated with the ETag/Last-Modified that ServeDir already sent (304 on a match). Missing files and dynamic responses keep no-store. Give an asset a new hashed name whenever its content changes, and review proxy/CDN rules that relied on no-store for /static. See static assets. |
| Generated release profile | New projects get [profile.release] with lto = "thin", codegen-units = 1 and strip = "symbols"; panic stays unwind. Since 13.0.0-alpha.4 they also get a [profile.dev] that keeps line tables for the application only (Build times). Existing manifests are not edited: copy the sections to adopt them. Release builds take longer and release backtraces lose symbol names (use strip = "debuginfo" to keep them). Measurements: release defaults. |
| Generated container runtime | The Dockerfile written by --docker, dockerize or a first deploy now uses gcr.io/distroless/cc-debian12:nonroot for the runtime stage instead of debian:bookworm-slim: no shell, package manager or HEALTHCHECK, still UID/GID 10001 with HOME=/app and a writable /app/data. New projects only; an existing Dockerfile is never replaced. Probe /health over HTTP and run /app/<name> db:migrate directly instead of through a shell. |
Interactive new --api | Without --default, cargo rullst new <name> --api no longer asks for a blueprint or for the build type, whose Full-Stack default discarded the flag and generated an HTML/HTMX app; it generates the Blank JSON API profile. Scripts that answered those two prompts must drop the answers. |
CLI new wizard | In a terminal, cargo rullst new without --default runs a new wizard: a blueprint screen with descriptions and file-tree previews, a build-type screen (Blank only), one database list that includes No database for Blank (replacing the separate “need a database?” confirmation), one optional-features checklist (AI, Redis, Dockerfile, Nix flake and the storage add-ons, replacing the separate AI and Redis questions) and a review with Create the project, Back and Cancel; Esc goes back. An invalid project name given on the command line is refused before the first question. Scripts that typed answers into the old prompts must use flags instead. --blueprint, --database, --no-database, --ai and --redis no longer require --default and skip their questions (the cargo_rullst::cli::Cli parser used directly still requires it). Without an interactive terminal (or with CI/TERM=dumb) new without --default now fails immediately with a hint instead of failing at the first prompt; nothing is created. The How to run: block after creation became numbered Next steps ending on the welcome-page URL. New --dry-run prints the plan and writes nothing. Generated files are unchanged for the same choices. See the CLI reference. |
| Minimal default features | Breaking (13.0.0-alpha.3): rullst has no default feature (it had orm, drivers-all and queue-sqlite) and rullst-orm has none (it had drivers-all), so cargo add rullst compiles no SQLx driver. A dependency that relied on those defaults now builds without the ORM, a database driver or Core’s SQLite queue: code that uses rullst::db/rullst::orm stops compiling, and Orm::init of a driver-less rullst-orm returns Error::Validation naming the feature to enable. List what the application uses, for example rullst = { version = "...", features = ["strict-sqlite", "queue-sqlite"] } or rullst-orm = { version = "...", features = ["strict-postgres"] }; drivers-all keeps all three drivers. Projects generated by cargo rullst new already disable the defaults and list their features. cargo rullst upgrade reports such dependencies (V13-RULLST-DEFAULT-FEATURES, V13-ORM-DEFAULT-FEATURES). |
Generated rullst-orm dependency | Projects with a SQLite, PostgreSQL, MySQL or MariaDB primary now declare rullst-orm = { ..., default-features = false, features = ["strict-..."] }, so the ORM’s default drivers-all no longer adds the other SQLx drivers (and the bundled SQLite C library) to a single-backend build. Existing projects can add default-features = false to that line; code that relied on another driver through it must enable that driver explicitly. Since 13.0.0-alpha.3 rullst-orm has no default driver at all (see Minimal default features). |
| ORM adapter aggregate features | The orm-polyglot feature of rullst and the polyglot feature of rullst-orm were removed. They enabled every optional storage adapter at once, including DuckDB’s bundled C++ build, which made the first build much slower. Cargo now refuses a manifest that names them: replace orm-polyglot with the adapters you use (orm-turso, orm-mongodb, orm-duckdb, orm-surrealdb, orm-qdrant), or polyglot with turso, mongodb, duckdb, surrealdb or qdrant. Each adapter feature is unchanged, and --all-features builds are not affected. cargo rullst upgrade reports a dependency or [features] entry that still names them. |
| Omni and offline sync removed | 13.0 removes cargo rullst make:omni, cargo rullst omni (including omni android --release) and the native-only offline-sync feature with rullst::offline_sync; the Tauri shells exposed no native API, so an installed Progressive Web App gives the same result. Run cargo rullst make:pwa to make the application installable, add --android --host <host> --package-id <id> for a Google Play package (Trusted Web Activity built with Bubblewrap, with /.well-known/assetlinks.json from rullst::pwa::AssetLinks), and package the same site for the Microsoft Store with PWABuilder (PWA guide). An existing omni-app/ keeps building with its own Tauri toolchain but receives no further fixes; retire it once the PWA replaces it. Remove offline-sync from rullst/rullst-core features (Cargo refuses it) and queue browser writes in IndexedDB with idempotency keys, keeping the server authoritative. cargo rullst upgrade reports an Omni shell or a script that runs the removed commands (V13-OMNI-REMOVED) and a manifest that enables the feature (V13-OFFLINE-SYNC-REMOVED). |
| Starter page language | The Blank starter renders with render_page_with_lang(&htmx, "en", ...) and the ERP page declares <html lang="en">, matching their English copy; render_page and the earlier ERP page declared pt-BR. Existing applications can make the same one-line changes. |
| Repository examples | The blog showcase selects its database like Server and Artisan (DATABASE_URL, .env, then Rullst.toml, now sqlite://blog.db?mode=rwc) and should be started from examples/blog. Post uses fail-closed tenant_column with the request’s TenantContext, and /posts/repository lists only the selected tenant. Its Nexus registration now declares nexus_tenant_column, so /nexus lists, searches, creates and changes posts only in the request tenant (another tenant’s record is 404) and hides tenant_id, which a form can no longer submit (422). /pricing, /billing and /checkout read provider credential presence (process environment, then .env) once at start-up instead of re-reading .env on every request, so a credential added later appears after a restart. Stories are bounded (120/4,000 characters, 100 per tenant, 64 KiB forms; 422, 403 or 413), listings show the newest 20, startup seeding no longer deletes posts that resemble old seeds, and pages render under the unrelaxed production CSP with same-origin assets, and the /omni guide page was removed with Omni. The non-working Wasm island page, the Tauri 1.5 shell and the Dioxus omni-app were removed; use cargo rullst make:island/make:pwa. In the SaaS example, note bodies with control characters receive 422 instead of 503, and membership.py rejects tenants made only of dots. |
| Windows paths in CLI and console output | Generator refusals (make:k8s, dockerize, introspect and the other no-clobber outputs), audit IDOR, unsafe-code and listener findings (and the compliance report built from them) and the development panic console now name files with / separators on Windows as well (k8s/deployment.yaml, not k8s\deployment.yaml). Adjust scripts that matched the backslash spelling. |
| Consumer scaffolds under symlinked directories | make:age-gate and make:privacy now inspect only the entries below the project directory for symlinks, so a project reached through a symlinked parent (macOS /var → /private/var, a Windows junction) is accepted. A symlink inside the project is still refused, as is a planned path outside it. |
| Core HSTS default | From 13.0.0-alpha.2 the staging and production header baseline sends Strict-Transport-Security: max-age=31536000; includeSubDomains instead of max-age=63072000; includeSubDomains; preload, and rullst_security::SecureHeadersConfig::default() uses the same value. preload consents to the browsers’ preload list, so it is now opt-in: set [security] hsts_preload = true (with hsts_max_age_seconds of at least 31536000 and hsts_include_subdomains = true, or the server refuses to start) to keep sending it. hsts_max_age_seconds = 63072000 restores the two-year max-age. |
Artisan and .env | From 13.0.0-alpha.2 db:migrate, db:rollback, db:status, db:seed and studio (through rullst::artisan!, check_and_run_artisan or Server::run) no longer load .env into the process environment, matching Server. DATABASE_URL is still read from .env. A migration or seeder that called std::env::var for a value that only .env defines must call rullst::config::project_setting instead, which reads the process environment and then ./.env. |
| Core WAF in development | From 13.0.0-alpha.2 Server in development (and apply_security_baseline with Environment::Development) runs the WAF rules in report-only mode: no request is refused, but each one staging and production would refuse logs a warn event (target rullst::security::waf) with the rule and path, and textual request bodies are buffered (bodies over 1 MiB are replayed in full). The new [security] waf_body_exempt_paths exempts the bodies of exact paths from inspection in every environment. |
| CSRF token on static-looking paths | From 13.0.0-alpha.2 a GET or HEAD for a path such as /report.json or /static/page receives CsrfToken when the browser sends a valid rullst_csrf cookie; it still never mints a token or sets the cookie there. |
| Multipart CSRF refusal | From 13.0.0-alpha.2 a multipart/form-data request without a _token field the CSRF middleware can read (its first _token must end within the first 64 KiB, so before any file input) is still refused with 403, but the body is Missing CSRF token: a multipart/form-data form must send its _token field before any file field (the CSRF check reads only the first 64 KiB of the body), or send the X-CSRF-Token header instead of Invalid or missing CSRF token. A readable but wrong token keeps the old text. Update tests or clients that compare that body. Forms read with the new rullst::uploads::Multipart get the same refusal in development; see form uploads. |
| Secure cookies over HTTPS | From 13.0.0-alpha.2 the rullst_csrf cookie, and every rullst_* cookie (such as rullst_session) passing through Server’s security baseline, carries Secure in any environment when the request arrived over HTTPS: an https request URI, or X-Forwarded-Proto: https from a proxy listed in [security] trusted_proxies with trust_forwarded_proto = true. Staging and production already set it. A development or test deployment reached over HTTPS no longer needs RULLST_ENV=staging for this, and its browser then sends those cookies only over HTTPS. |
| CSRF cookie on cacheable responses | From 13.0.0-alpha.2 a GET or HEAD response that shared caches may store (its Cache-Control has public or s-maxage and neither private nor no-store) no longer sets the rullst_csrf cookie, so a cache cannot hand one visitor’s token to another. A page that renders a form must not be publicly cacheable; give it private or no cache policy. The new rullst::cache_control::public/private layers label dynamic routes, such as downloads and feeds, that may be cached. |
| Connect mock credentials | From 13.0.0-alpha.3, outside rullst-connect’s own unit tests, empty or mock_* provider credentials need the mock feature (oauth-mock on the rullst facade). Without it try_new, Connect::driver and OidcProvider::discover return ConnectError::InvalidConfiguration { field: "credentials", .. } instead of building a provider whose code exchange later failed with ConnectError::Offline, and the deprecated new constructors return a disabled provider (CredentialMode::Invalid, redirect about:blank). Set real credentials, build the provider only when they are configured, or enable the feature in development and test builds. |
| Connect mock authorization URL | From 13.0.0-alpha.3 a provider in mock mode returns the configured redirect_url with code=mock_authorization_code, the state and any PKCE challenge, instead of https://example.invalid/rullst-connect/mock?provider=..., which a browser could not open. Code that matched the old URL, or that redirected to its own callback to work around it, can follow the returned URL directly. |
| Account mail language and product name | From 13.0.0-alpha.3 the password controller generated for SaaS, LMS and cargo rullst auth projects writes account emails in the request’s language (the rullst::i18n Locale when the router has LOCALES.layer(), else Accept-Language, matched to English, Brazilian Portuguese or Spanish, else DEFAULT_MAIL_LOCALE) instead of always MailLocale::En, and names the product APP_NAME (environment, then .env) before the APPLICATION_NAME constant. Existing projects are not changed; to adopt it, pass a MailLocale from MailLocale::from_preference to AccountMail::new in send_account_mail. |
| Production log defaults | From 13.0.0-alpha.3, in staging and production Server no longer prints [HTTP] lines for /health and /ready requests that answer below 400 (failing probes still appear); set RULLST_LOG_HEALTH_PROBES=true (process environment or .env) to restore them, and note that an invalid value now stops startup. Without RUST_LOG, the default tracing filter is info,sqlx=warn instead of info, which drops SQLx’s own INFO events such as acquired connection; set RUST_LOG=info (or info,sqlx=info) to keep the previous output. |
| Connect GitHub email | From 13.0.0-alpha.3 GithubProvider also calls GET /user/emails after GET /user: ConnectUser::email is the address GitHub marks primary (which may differ from the public profile address) and email_verified is that entry’s verified flag (Some(true) or Some(false)) instead of always None. When the call fails (for example a token without the user:email scope) or lists no primary address, email stays the public profile address and email_verified stays None. Code that linked or created accounts on the GitHub email should now require email_verified == Some(true); transports that answered every GET with the /user body keep the old result. cargo rullst upgrade reports GithubProvider and Connect::driver("github", ...) (V13-CONNECT-GITHUB-EMAIL). |
| Account mail link lifetime | From 13.0.0-alpha.3 AccountEvent::PasswordReset accepts a remaining lifetime of 1–1440 minutes (24 hours) instead of 1–30; the verification and email-change links keep 1–30. Every account link now states whole hours as hours (“This link expires in 2 hours.”, “1 hora”) and one minute in the singular (“1 minute”); other values are unchanged (“30 minutes”). |
| Generated reset link lifetime | From 13.0.0-alpha.3 new SaaS, LMS and cargo rullst auth projects bound RESET_TOKEN_LIFETIME_MINUTES (src/models/password_reset.rs) to 10–1440 with a compile-time assertion, and the forgot-password answer states that value instead of a hard-coded “30 minutes”. In an alpha.2 project a value above 30 failed at send time (AccountMail refused it, so no email went out) and the answer still said 30 minutes: to change the lifetime, copy reset_requested_notice and lifetime_words from a new project’s password_controller.rs and the assertion from its password_reset.rs. |
| CLI deploy targets removed | Breaking, from 13.0.0-alpha.4: cargo rullst deploy no longer writes fly.toml, railway.json or render.yaml or runs flyctl/railway; --platform fly|railway|render fails before writing anything. It writes, when absent, the Dockerfile (with its dependency-cache stage), compose.yaml (the app and Caddy with automatic HTTPS; read-only root filesystems, every capability dropped except Caddy’s NET_BIND_SERVICE, no new privileges, a /health check; works with docker compose and podman compose) and the Caddyfile (it checks the app’s /health every 5 seconds and holds requests for up to 15 seconds while the app restarts), and trusts Caddy in Rullst.toml as deploy --platform vps did; --platform vps is still accepted. compose.yaml replaces docker-compose.prod.yml, which stays yours. It refuses a Cargo.toml package name that is not a valid Cargo name. Provider files you already have keep working with the provider’s CLI; other targets are in Advanced deployment. cargo rullst upgrade reports fly.toml, render.yaml, railway.json, railway.toml and scripts that run the removed targets (V13-DEPLOY-TARGETS-REMOVED). For library users, cargo_rullst::blueprints::deploy::{FLY_TOML, RAILWAY_JSON, RENDER_YAML, DOCKER_COMPOSE_PROD} were removed (COMPOSE_YAML is new). |
| SQLite database in the deployed image | From 13.0.0-alpha.4, the Dockerfile that cargo rullst deploy and cargo rullst dockerize write for a project whose Rullst.toml [database] url (or, without one, .env.example DATABASE_URL) is SQLite sets ENV DATABASE_URL=sqlite:///app/data/rullst.db?mode=rwc, as new --docker already did, so the database lives in the /app/data volume. Earlier dockerize and deploy --platform vps left it out: the starters’ relative sqlite://db.sqlite then pointed into the image’s read-only /app, and the app stopped at startup under a read-only root filesystem. An existing Dockerfile is kept, and deploy warns when it lacks the line: add it to the last stage, or set DATABASE_URL in .env.production. |
| CLI Foundry, Nix, Buildah and gRPC generators removed | Breaking, from 13.0.0-alpha.4: cargo rullst foundry:init, foundry:deploy, nixify, generate:buildah, make:grpc and the new --nix and new --buildah flags were removed, with the wizard’s “Nix flake” choice. Files they generated stay yours and are no longer regenerated; Advanced deployment shows Nix, Buildah, Podman Quadlet and Kubernetes setups, and gRPC code keeps working with your own tonic/tonic-build setup. cargo rullst upgrade reports Foundry.toml, a flake.nix written by nixify, build_buildah.sh, src/grpc/mod.rs and scripts that run these commands (V13-DEPLOY-GENERATORS-REMOVED). For library users, cargo_rullst::cli::Commands::{FoundryInit, FoundryDeploy, GenerateBuildah, Nixify, MakeGrpc} and the buildah/nix fields of Commands::New and ProjectScaffoldOptions were removed, with the generators::foundry and generators::grpc modules, generators::project::{generate_buildah_script, generate_nix_files} and the deprecated generators::project::create_new_project. |
| CLI self-update removed | Breaking, from 13.0.0-alpha.4: the CLI no longer downloads, verifies or replaces its own binary. cargo rullst update check, update verify, update stage and update install review|apply|recover were removed, and update guided lost --scope, --root, --allow-major and --prerelease (it now runs only the project flow, for a --to in the CLI’s major). Install or update the CLI with cargo install cargo-rullst --version =<version> --locked; the native executables stay attached to each GitHub release for manual verification. At most once a day, in an interactive terminal, the CLI prints that command when crates.io has a newer release of its major (a newer prerelease too when the CLI is a prerelease); RULLST_DISABLE_UPDATE_CHECK, CARGO_NET_OFFLINE, CI and non-terminal streams silence it. update project prepare|verify|review|apply|recover are unchanged. cargo rullst upgrade reports scripts and workflows that run the removed commands (V13-CLI-SELF-UPDATE-REMOVED). |
CLI ai removed, footprint renamed green | Breaking, from 13.0.0-alpha.4: cargo rullst ai (the terminal assistant of the earlier 13.0 prereleases, with ai connect, ai fix, ai review and ai upgrade) was removed; cargo rullst ai … now prints what replaces it and exits with status 1. Use your editor’s or terminal’s AI assistant with the project’s AGENTS.md and .llms.txt (cargo rullst generate:ai-context writes and checks them) and the published llms.txt. Keys saved by ai connect stay in ~/.config/rullst/credentials.toml ($XDG_CONFIG_HOME/rullst/, or %APPDATA%\rullst\ on Windows) and its git checkpoints under refs/rullst/ai-checkpoints/ until you delete them. The rullst-ai crate is unchanged. cargo rullst footprint is now cargo rullst green, an Experimental command of the same cargo-rullst package (no separate install): it reports only measured figures (binary and --image size, idle RSS, Cargo.lock dependency count and, with --bench, CPU time per request) and an SCI estimate labelled estimate from your --cpu-watts and --grid-intensity; it drops requests/s, latency, peak RSS and RAPL energy, and its JSON is rullst.green.v1 instead of rullst.cli-footprint.v1. cargo rullst upgrade reports scripts, workflows, Makefiles and justfiles that run rullst ai or rullst footprint (V13-CLI-AI-REMOVED). See the green guide. |
| Core error console assistant panel | From 13.0.0-alpha.4 the development error page’s “Fix with cargo rullst ai” panel (cargo rullst ai fix <error-id>) is an “Ask your AI assistant” panel: a read-only prompt to copy into your editor’s or terminal’s assistant, with the error message, its location, the first five project stack frames and the request method and path (no query string, headers, cookies or body), a copy button that only the page’s nonce-carrying script shows, and, when Server records the error, the path /_rullst/errors/{id} of its JSON context. The retired POST /_rullst/autofix still answers loopback peers 410 Gone with success: false and never edits files, but its message points to that panel and its command field is replaced by context_url: "/_rullst/errors/<error-id>"; a client that read command must read context_url. catch_panic_middleware records a panic only inside Server’s debug Development stack, which serves /_rullst/errors/{id}; the hot-reload router and an application’s own router no longer record panics that nothing serves. No 12.x API changes. |
| CLI starters moved to the examples | Breaking, from 13.0.0-alpha.4: cargo rullst new offers three starters: Blank, API (--blueprint api, the same as --api) and SaaS. The LMS, Blog, Portfolio and ERP blueprints were removed: the wizard no longer lists them or asks a separate web/API question, and --blueprint lms|blog|portfolio|erp fails before writing anything with a report naming the example and its GitHub URL (exit status 1). LMS, ERP and Portfolio are complete applications in the repository’s examples/lms, examples/erp and examples/portfolio, tested in its workspace; the blog showcase is examples/blog. Copy one to start from it. Applications generated earlier from these blueprints keep working: their code is theirs, so no upgrade rule applies. Library users of the cargo_rullst crate: the blueprints::{lms, blog, portfolio, erp} modules and LMS_BLUEPRINT_ID, BLOG_BLUEPRINT_ID, PORTFOLIO_BLUEPRINT_ID and ERP_BLUEPRINT_ID were removed (IDs 1, 3, 4 and 5 are never reused), and BlueprintChoice::id() became starter(), which returns the moved example (MovedBlueprint) as its error. |
| ORM derive dependencies | From 13.0.0-alpha.4 the code that #[derive(Orm)] generates reaches tracing and tokio through rullst_orm, so a crate that derives a model needs only rullst-orm (or the rullst facade with orm); the derive also implements FromRow (see ORM FromRow and SQLx macros). Earlier releases failed to compile (E0433, “cannot find tracing”) unless the application declared tracing and tokio itself. The rullst.orm.query spans keep their name, target, level and fields. A tracing or tokio dependency declared only for the derive can be removed; keep it where your own code uses it. Projects generated by cargo rullst new still declare tracing: the Blank starter’s database status logs with tracing::warn!. No API changes. |
ORM FromRow and SQLx macros | Breaking, from 13.0.0-alpha.4: #[derive(Orm)] implements SQLx’s FromRow for the model itself, generic over the row type like SQLx’s derive, with the same meaning for the options a model accepts (field #[sqlx(skip)], #[sqlx(default)], #[sqlx(json)], #[sqlx(json(nullable))] and struct-level #[sqlx(default)]; relation fields are never read and start from Default::default()). rullst-orm and rullst-core no longer enable SQLx’s macros feature, and no Rullst crate derives SQLx traits, so an application builds without sqlx-macros and without a second, build-host copy of SQLx and its SQLite driver; a generated starter needs no change to its sqlx dependency. A model that also derives FromRow no longer compiles: unless the application enables SQLx’s macros itself, rullst::db::FromRow and rullst_orm::FromRow are now only the trait (“expected derive macro, found trait”), and with them enabled any FromRow derive is a second implementation (E0119). Remove FromRow from the model’s derive list and from the use line that imports it. A model that keeps its own FromRow (hand-written, or SQLx’s derive) declares the new #[orm(custom_from_row)]. Structs that are not models, sqlx::Type/Encode/Decode derives and sqlx::query! and the other SQLx macros need SQLx’s derive or macros feature on the application’s own sqlx dependency, for example sqlx = { version = "0.9", default-features = false, features = ["derive"] }, which builds sqlx-macros again. cargo rullst upgrade locates models deriving both (V13-ORM-FROM-ROW) and SQLx macros that no manifest enables (V13-SQLX-MACROS). |
| Nexus AI assistant feature | From 13.0.0-alpha.4 the Nexus AI assistant (/nexus/chat, /nexus/chat/query) is the ai feature of rullst-nexus, on by default for the crate, and the new nexus-ai feature of the rullst facade, whose nexus feature no longer enables it, so Nexus builds without rullst-ai. Without it the panel shows no assistant link, both routes answer 404 Not Found with a notice naming the feature (behind the same access policy), the security and telemetry pages report the assistant as not enabled, and detect_ai_provider() returns (false, "AI assistant not enabled in this build"); the public handlers keep their signatures and render that notice. With the feature, routes and behaviour are unchanged. An application that uses the assistant adds nexus-ai (rullst-nexus users without default features add ai); cargo rullst new enables it when the SaaS starter is created with AI. cargo rullst upgrade reviews Nexus dependencies without it (V13-NEXUS-AI-FEATURE). No API changes. |
| Generated starter dependencies | From 13.0.0-alpha.4 cargo rullst new no longer lists tracing-subscriber, which no generated code used (Rullst sets up logging with its own copy), and the SaaS starter lists nexus-ai only when AI is chosen. An existing project whose code does not use tracing_subscriber can remove the dependency; nothing else changes. |
| TLS crypto provider | From 13.0.0-alpha.4 every TLS connection Rullst makes uses aws-lc-rs as the rustls crypto provider; ring remains only the primitives library (HMAC, digests, randomness, signatures) of the crates that use it directly. The SQLx dependencies of the framework crates, the examples and newly generated projects use tls-rustls-aws-lc-rs instead of tls-rustls (ring) or tls-rustls-ring, orm-mongodb uses MongoDB’s rustls-tls-aws-lc, mail-smtp uses lettre’s aws-lc-rs, and the Redis Streams adapter installs aws-lc-rs instead of ring as the rustls process default when none is installed (ORM Redis connections now do the same over TLS). Server start-up (with Core’s orm, storage-s3 or telemetry feature), the OTLP exporter and the rullst-studio binary also install aws-lc-rs as the process default when none is installed, so reqwest, lettre and Redis no longer depend on which component initialised rustls first; a provider the application installs first is kept. No API changes. An application whose own sqlx dependency lists tls-rustls (as projects generated earlier do) or tls-rustls-ring keeps SQLx on ring, because SQLx prefers ring when any crate enables it: change the feature to tls-rustls-aws-lc-rs. cargo rullst upgrade reviews such a dependency (V13-SQLX-TLS-RING). See cryptography providers. |
CLI auth on database starters | From 13.0.0-alpha.4, cargo rullst auth works in a fresh Blank or API starter with SQLite, PostgreSQL, MySQL or MariaDB, before or after its first db:migrate. alpha.3 refused with “refusing to add a second users table migration” because the starter’s m20260601000000_create_users_table.rs already creates users. While that migration is unchanged, the command now keeps it and writes m<timestamp>_add_account_columns_to_users_table.rs, which adds email, password_hash, oauth_provider, oauth_id, is_admin and session_epoch and the unique index users_email_unique, and gives existing rows the placeholder email user-<id>@no-email.invalid and no password; it also replaces the starter’s unchanged two-field User in src/main.rs/src/lib.rs with pub use crate::models::user::User;. A changed or unknown users migration is still refused, now with “refusing to change the users table”. When every file it writes already exists (the SaaS starter, or a second run) it reports “Authentication is already scaffolded”, writes nothing and exits with status 0 instead of failing. Projects that followed the alpha.3 workaround (deleting the starter migration and database) keep working. |
CLI --no-database files | From 13.0.0-alpha.4, cargo rullst new --no-database (Blank or API) no longer writes an empty rullst.db, and with --docker its Dockerfile no longer sets DATABASE_URL=sqlite:///app/data/rullst.db?mode=rwc; SQLite projects still get both. A database-free project created earlier can delete the empty rullst.db and that Dockerfile line. |
CLI make:migration:auto without a database to compare | From 13.0.0-alpha.4, cargo rullst make:migration:auto exits with status 1 and a “No migration generated” report when there is no .env, no DATABASE_URL (a --no-database project), a non-SQLite DATABASE_URL or a Turso primary. alpha.3 printed a warning, exited with status 0 and suggested cargo rullst db:migrate to “apply the generated migrations” although none was written. A database already in sync with the models still exits with status 0, now without that next step. Scripts that ran it in such projects and relied on status 0 must stop running it there. |
| Internals hidden from the API documentation | From 13.0.0-alpha.4 these items are #[doc(hidden)]: they still compile, but they are no longer documented and are not part of the 13.x compatibility promise. Core: html::optional_attr (called by html!), rpc::route and rpc::handle_request (called by #[server_function]), the server::dylib_loader module (the hot-reload loader of Server) and the artisan::runner module, whose check_and_run_artisan stays documented as artisan::check_and_run_artisan. ORM: CURRENT_TX (use Orm::transaction), tenant::CURRENT_TENANT (use with_tenant and get_tenant_id), schema::QUERY_LOGGING, schema::QUERY_TIMEOUT_SECS and schema::MAX_QUERY_LIMIT (use enable_query_log, disable_query_log, set_query_timeout, set_max_query_limit and their getters) and privacy::encrypt_aes_gcm/decrypt_aes_gcm. Auth: the passkey::cbor module. Connect: the impl_standard_redirect_url! and impl_standard_refresh_token! macros, the empty macros module and client::DEFAULT_HTTP_CLIENT. Security: current_timestamp_str and get_real_rss_memory_mb (use rullst_core::radar::get_process_memory_mb). Nexus: field_kind_sql and generate_smart_nexus_ai_response. Code that names them keeps compiling; move to the documented replacement, because a later major release may make them private. No other API changes. |
The 12.0→12.1 guide still applies to applications that have not adopted the 12.1 account, payment and deployment changes. Skipping directly to 13 does not perform those application/database migrations.
html! returns Markup
cargo rullst upgrade reports V13-HTML-MARKUP-RETURN for a function that
declares a String (String, Html<String>, Result<String, _>) and
returns html!, and V13-HTML-RAW-MARKUP for RawHtml around html!
output. Before:
use rullst::html;
use rullst::html::RawHtml;
fn badge(label: &str) -> String {
html! { <span class="badge">{label}</span> }
}
fn rows(names: &[&str]) -> String {
let rows: String = names
.iter()
.map(|name| html! { <tr><td>{name}</td><td>{RawHtml(badge("new"))}</td></tr> })
.collect();
html! { <table>{RawHtml(rows)}</table> }
}
After:
#![allow(unused)]
fn main() {
use rullst::html;
use rullst::html::Markup;
fn badge(label: &str) -> Markup {
html! { <span class="badge">{label}</span> }
}
fn rows(names: &[&str]) -> Markup {
let rows: Markup = names
.iter()
.map(|name| html! { <tr><td>{name}</td><td>{badge("new")}</td></tr> })
.collect();
html! { <table>{rows}</table> }
}
assert_eq!(
rows(&["<Ana>"]),
"<table><tr><td><Ana></td><td><span class=\"badge\">new</span></td></tr></table>"
);
}
Keep RawHtml (or Markup::from_trusted) only for trusted HTML produced
outside html!, such as a sanitizer’s output, and never for request data.
Projects generated by the 12.0 CLI
The 12.0.0 and 12.1.2 CLIs generate the same Rust sources, Cargo.toml,
Rullst.toml and .env for the Blank and Blog starters, so the rows above and
their findings apply unchanged. Three generated files differ; cargo rullst upgrade reports each under the “Build files from the 12.0 CLI” row:
.cargo/config.tomlcontains a[target.x86_64-pc-windows-msvc]block whoserustflagspass the unsupported MSVC linker flag/DEBUG:FASTLINK(V13-MSVC-FASTLINK). Delete that block; 12.1 no longer generates it..gitignorelists/Cargo.lock(V13-CARGO-LOCK-IGNORED). Remove that line and commit the lockfile, so builds and deployments use the versions you tested. The upgrade itself works without a committed lockfile.- A Dockerfile written by
--dockerordockerizerunscargo build --release(V13-DOCKER-UNLOCKED-BUILD). Add--lockedonce the lockfile is committed.
The starter’s rullst-orm dependency relies on default features; since
13.0.0-alpha.3 rullst-orm has none, so after the upgrade it has no SQLx
driver until the backend is named. The V13-ORM-DEFAULT-FEATURES finding
gives the fix.
Upgrading an alpha.2 application to alpha.3
This checklist moves an application from 13.0.0-alpha.2 to 13.0.0-alpha.3. The rows above that name alpha.3, and the ORM database-managed columns, ORM dirty tracking and Omni rows, are the changes in between. The rehearsal on generated starters shows the expected result.
-
Prepare. Work on a branch with a clean tree and a committed
Cargo.lock, record the currentcargo testresult, and back up every database (check that the backup restores). -
Install the alpha.3 CLI.
cargo install cargo-rullst --version =13.0.0-alpha.3 --locked --force, thenrullst --versionmust print13.0.0-alpha.3. -
Name the features.
rullst(which enabledorm,drivers-allandqueue-sqlite) andrullst-orm(drivers-all) have no default features any more. A dependency that relied on them lists what the application uses, for examplerullst = { version = "=13.0.0-alpha.3", default-features = false, features = ["orm", "strict-sqlite", "queue-sqlite"] }(drivers-allkeeps the three SQLx drivers). Removeoffline-syncfromrullstandrullst-corefeatures. Projects generated bycargo rullst newalready disable the defaults and list their features. -
Inspect. Run
cargo rullst upgrade --dry-runand fix everyMUST-CHANGEfinding first (V13-OFFLINE-SYNC-REMOVEDis the one alpha.3 adds). These findings concern alpha.3 changes and usually need an edit:V13-ORM-SKIP-MANAGED-COLUMNS: replace#[orm(skip)]with#[orm(db_default)]onUser::is_adminandUser::session_epoch(SaaS, LMS andcargo rullst authmodels generated by alpha.2) and on any other column the database manages. No schema change is needed.V13-ORM-TRANSACTION-ERRORS:Orm::transactionreturns its closure’s own error type, so match that error instead ofDatabaseError. A closure whose error type lacksFrom<rullst_orm::Error>(such asString) no longer compiles, whichcargo checkreports; returnrullst_orm::Erroror an application error that wraps it.V13-RULLST-DEFAULT-FEATURESandV13-ORM-DEFAULT-FEATURES: step 3.V13-CONNECT-GITHUB-EMAIL: the GitHub provider now returns the primary address and GitHub’s verified flag; link or create accounts on it only whenemail_verified == Some(true).V13-OMNI-REMOVEDandV13-OFFLINE-SYNC-REMOVED: replace the Omni shell withcargo rullst make:pwa, as the Omni row describes.
The plan reads the application’s version from its manifests and
Cargo.lockand applies only the rules introduced after 13.0.0-alpha.2, which are the ones above; itsRules:line says how many earlier rules it skipped. If it names 13.0.0-alpha.1 instead, a requirement still has the caret form of the alpha.1 CLI ("13.0.0-alpha.1"), and the alpha.2 rules are listed too.--all-rulesalso lists the rules for the 12.x to 13 changes that alpha.2 already contained, such asV13-REFERRER-NO-REFERRER,V13-MAIL-FACADE-CONFIG,V13-LMS-RECORD-PROGRESSandV13-HOT-RELOAD-MACHINE-ENDPOINTS; an alpha.2 application already runs with those changes, so they need no edit for this step. -
Apply.
cargo rullst upgradepins every Rullst dependency, including directrullst-auth,rullst-securityorrullst-connectlines, to=13.0.0-alpha.3, runscargo fixandcargo check --locked, and restores the files if a step fails (--keep-on-failurekeeps the failed state for diagnosis). Make the edits from step 4, then update the dependencies of the changelog’s Dependency security entries that the lockfile contains:cargo update -p ammonia -p rustls-webpki -p webpki-roots -p webpki-root-certs. -
Read the alpha.3 rows that no rule locates and adjust the application where it depends on them: ORM transactional migrations and the pool (no
Orm::pool()inside awithin_transaction()migration), Connect mock credentials and Connect mock authorization URL (mock credentials need themockfeature,oauth-mockon the facade), Production log defaults (health probes are no longer logged, SQLx INFO events are hidden, and an invalidRULLST_LOG_HEALTH_PROBESstops startup), and the opt-in ORM dirty tracking, Nexus time zone (Nexus::with_time_zone, in the Nexus dates and times row) and Account mail language and product name. Workarounds for alpha.2 defects that the changelog’s “Fixes from alpha.2 dogfooding” lists, such as raw SQL to filter onis_admin, can then go. -
Verify with the production feature set:
cargo build,cargo testandcargo clippy --all-targets -- -D warnings;db:migrateagainst a restored copy of the production database; then sign-in, “Sign out everywhere”, Nexus access for anis_adminaccount, each configured provider and the health probes on staging before production.
Upgrading an alpha.3 application to alpha.4
This checklist moves an application from 13.0.0-alpha.3 to 13.0.0-alpha.4.
The rows above that name alpha.4 are the changes in between. Most of them
change the CLI and the files it generates. In the framework crates,
#[derive(Orm)] now implements FromRow and Rullst no longer enables SQLx’s
macros, the facade’s nexus feature leaves out the AI assistant, every TLS
connection uses aws-lc-rs, and some internals are hidden from the API
documentation. Work through the steps in order.
-
Prepare. Work on a branch with a clean tree and a committed
Cargo.lock, record the currentcargo testresult, and back up every database (check that the backup restores). -
Install the alpha.4 CLI.
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked --force, thenrullst --versionmust print13.0.0-alpha.4.cargo rullst green(the formerfootprint) is part of this package; there is nothing else to install.cargo rullst aiwas removed: use your editor’s or terminal’s AI assistant with the project’sAGENTS.md(cargo rullst generate:ai-context) and the publishedllms.txt. -
Inspect.
cargo rullst upgrade --dry-runapplies only the rules introduced after 13.0.0-alpha.3. These eight can appear:V13-ORM-FROM-ROW(MUST-CHANGE, step 5): a model that derivesFromRowbesideOrm. Every model generated before alpha.4 does (#[derive(Debug, Clone, FromRow, Orm)]).V13-SQLX-MACROS(MUST-CHANGE, step 5): a SQLx derive on a struct or enum that is not a model, or asqlx::query!-style macro, while no manifest enables SQLx’sderiveormacrosfeature.V13-SQLX-TLS-RING(REVIEW, step 6): asqlxdependency or a[features]entry that enablestls-rustls,tls-rustls-ringortls-rustls-ring-webpki. Projects generated before alpha.4 listtls-rustls.V13-NEXUS-AI-FEATURE(REVIEW, step 7): arullstdependency withnexusornexus-tzbut notnexus-ai, or arullst-nexusdependency without default features and withoutai.V13-CLI-AI-REMOVED,V13-CLI-SELF-UPDATE-REMOVED,V13-DEPLOY-TARGETS-REMOVEDandV13-DEPLOY-GENERATORS-REMOVED(REVIEW, step 9): scripts that run removed commands, and the files of the removed deploy targets and generators.
Steps 9 to 11 also list changes that no rule locates; read them even when the plan has no finding.
-
Apply, keeping a failed state. A model that still derives
FromRowdoes not compile against alpha.4, and one without it does not compile against alpha.3, so the edits of step 5 come after the version change. Runcargo rullst upgrade --keep-on-failure: it pins every Rullst dependency to=13.0.0-alpha.4, runscargo fixandcargo check --locked, and, when they fail, keeps the edited files instead of restoring them and names the must-change findings. When the plan has noMUST-CHANGEfinding, a plaincargo rullst upgradeis enough; go on with step 6. -
Fix the models and SQLx macros.
- Remove
FromRowfrom each model’s derive list and from theuseline that imports it:#[derive(Debug, Clone, FromRow, Orm)]becomes#[derive(Debug, Clone, Orm)], anduse rullst::db::{Orm, FromRow, RowSnapshot};becomesuse rullst::db::{Orm, RowSnapshot};. A model that keeps its ownFromRow(hand-written, or SQLx’s derive) declares#[orm(custom_from_row)]instead. - For SQLx derives on other types (
FromRow,Type,Encode,Decode) andsqlx::query!-style macros, addderiveormacrosto the features of the application’s ownsqlxdependency. This compilessqlx-macrosagain (Build times).
- Remove
-
Switch SQLx to aws-lc-rs. In the application’s
sqlxdependency and in any[features]entry, replacetls-rustls(ortls-rustls-ring,tls-rustls-ring-webpki) withtls-rustls-aws-lc-rs. SQLx prefers ring whenever any crate enables it, so one such feature keeps every database connection on ring while Rullst’s other TLS connections use aws-lc-rs. Keep thesqlxdependency itself: the CLI reads it to recognise a SQLx project.tls-rustls-ring-native-rootsis not reported, because switching it changes the trusted roots; review it yourself (TLS crypto provider row, cryptography providers). -
Decide on the Nexus AI assistant. If the application uses
/nexus/chat, addnexus-aito therullstfeatures (oraito arullst-nexusdependency without default features). Otherwise nothing is needed: both chat routes answer404 Not Foundwith a notice, the panel shows no assistant link, and Nexus no longer bringsrullst-aiinto the build. -
Validate. Run
cargo rullst upgradeagain. It makes no further version edits and checks the result withcargo fixandcargo check --locked. -
Update scripts, CI and other tools. Replace the commands the review rules name:
rullst ai …with your own assistant, andrullst footprintwithrullst green(V13-CLI-AI-REMOVED);update check|verify|stage|installandupdate guided --scope|--rootwithcargo install cargo-rullst --version =<version> --locked, keepingupdate project …(V13-CLI-SELF-UPDATE-REMOVED);deploy --platform fly|railway|render,foundry:init,foundry:deploy,nixify,generate:buildahandmake:grpc(V13-DEPLOY-TARGETS-REMOVED,V13-DEPLOY-GENERATORS-REMOVED). The files they generated (fly.toml,render.yaml,railway.json,Foundry.toml, anixifyflake.nix,build_buildah.sh,src/grpc/mod.rs) stay yours and keep working with their own tools; keep or delete them.
No rule locates these; check them yourself:
cargo rullst new --blueprint lms|blog|portfolio|erpfails before writing anything: copy the application fromexamples/instead;cargo rullst make:migration:autoexits with status 1 when there is no SQLite database to compare (no.env, noDATABASE_URL, a non-SQLite URL or a Turso primary);- a client of the retired
POST /_rullst/autofixreadscontext_urlinstead ofcommand.
-
Regenerate the deployment files. An application deployed with the files of
cargo rullst deploy --platform vpsorcargo rullst dockerizerunscargo rullst deployagain. It keeps an existingDockerfileandCaddyfileand writescompose.yaml, which replacesdocker-compose.prod.yml(that file stays until you remove it). Then:- for SQLite,
deploywarns when theDockerfilelacksENV DATABASE_URL=sqlite:///app/data/rullst.db?mode=rwc. Add it to the last stage, or setDATABASE_URLin.env.production; otherwise the database path points into the image’s read-only/appand the app stops at startup; - the new
Caddyfilechecks/healthevery 5 seconds and holds requests for up to 15 seconds (lb_try_duration) while the app restarts. To get it, move the old file aside and runcargo rullst deployagain, or copy itsreverse_proxyblock.
cargo rullst upgradereports neither file; start the stack as the Linux VM guide shows. - for SQLite,
-
Optional clean-ups. No rule reports these.
- A
tracingortokiodependency declared only for#[derive(Orm)]can go (ORM derive dependencies row); keep it where your own code uses it.tracing-subscribercan go when no code usestracing_subscriber. - A SaaS application whose code does not import
rullst_connectcan remove that dependency. - Copy the
[profile.dev]sections that new projects get (Build times). - Code that names an item now hidden from the documentation keeps compiling; move to the replacement that the “Internals hidden from the API documentation” row names.
- A
--no-databaseproject can delete its emptyrullst.dband, with--docker, the Dockerfile’sDATABASE_URLline. - If
Cargo.lockhascrypto-bigint0.7.6 to 0.7.8, which crates.io yanked, runcargo update -p crypto-bigint.
- A
-
Verify with the production feature set:
cargo build,cargo testandcargo clippy --all-targets -- -D warnings;db:migrateagainst a restored copy of the production database; then, on staging, sign-in, Nexus (with or without the assistant), each configured provider and the health probes before production.
Why the candidate uses a new major
Adding optional privacy APIs, a new crate or more features does not by itself require a major version. A fully compatible increment would belong in a minor release under the compatibility policy.
The current candidate does change a supported CLI/configuration contract:
13.0 removes commands and public items that 12.x applications can use (the
rows marked Breaking above list them), for example
cargo rullst make:omni and cargo rullst omni, the offline-sync feature
with rullst::offline_sync, and the Commands::MakeOmni/Commands::Omni
variants and OmniPlatformChoice of the cargo-rullst library. This
intentional change supports retaining v13 for the synchronized release train;
it does not mean every application’s Rust code needs rewriting. The Omni row
above documents the replacement.
A green version-aware SemVer job alone cannot establish that this candidate
could be renamed to 12.2. The current command derives the release type from the
manifest; the 12.1-to-13 comparison permits major changes. The hosted comparison
at 4773171a reported 0 executed checks and 254 skipped checks for compared
libraries. Treat that as no Rust API compatibility result, not as proof that
breaking changes are absent. Any reconsideration as a minor release requires
an explicit minor-level API comparison plus review and regression tests for
CLI, configuration, serialized contracts and generated consumers. Resolve the
known Android compatibility change before making that claim.
Use the target-major CLI
A v12 CLI cannot learn v13 migration rules just by downloading another binary: install the v13 CLI, then start a new invocation of that v13 CLI for the project migration.
To evaluate the published prerelease, install its CLI from crates.io. Cargo
never selects a prerelease on its own (cargo install cargo-rullst installs
the end-of-life 12.x CLI), so name the exact version:
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
rullst --version
The output must identify 13.0.0-alpha.4. The projects it generates pin the
framework packages with =13.0.0-alpha.4, as described at the top of this
guide. Reinstall with --force to go back to 12.x (--version '^12') or to
move to a later prerelease.
To evaluate unreleased main changes instead, build the CLI from the reviewed
checkout:
cargo build --locked -p cargo-rullst
./target/debug/rullst --version
Use that explicit executable path instead of assuming a rullst already on
PATH is the same binary. This is a local source build, not authenticated
published distribution evidence. Generated projects then refer to the matching
local framework packages; keep that checkout available and review those paths.
The final package rehearsal must independently prove registry-based consumers.
Prepare, verify, review and apply
Keep a restorable application and database backup. Quiesce writers during file
application/recovery and review the application’s ignored files, toolchain and
Cargo configuration. The Git working directory’s tracked and non-ignored files
are copied into private preparation storage; the root Cargo.lock is included
even if ignored. Typical ignored secret files and build outputs are excluded.
Using the selected v13 executable, the structured sequence is:
rullst update project prepare --project /path/to/app --to 13.0.0-alpha.4 --json
rullst update project verify --prepared PREPARED_DIRECTORY --dry-run --json
rullst update project verify --prepared PREPARED_DIRECTORY --allow-project-code --json
rullst update project review --verified VERIFIED_DIRECTORY --json
rullst update project apply --verified VERIFIED_DIRECTORY --approved-review REVIEW_SHA256 --json
Use the directories returned by the preceding commands. Inspect the complete
review before supplying its digest. Verification executes trusted build scripts,
procedural macros and tests, and is offline by default; the private copy is not
a sandbox. Supply --allow-network only after reviewing that separate need.
Select the application’s actual feature policy (--features, --all-features
or --no-default-features) instead of assuming default features cover production.
Preparation accepts supported versioned dependency declarations and preserves their TOML structure. Unversioned path/git dependencies, ambiguous ranges, unsupported source majors, stale inputs and unresolved source findings require manual review. Verification requires every managed framework package in the candidate lockfile to resolve to the selected exact target. It runs locked workspace checks and tests before review can authorize manifest/lockfile edits.
After application, source-file recovery uses the same reviewed operation:
rullst update project recover --verified VERIFIED_DIRECTORY --approved-review REVIEW_SHA256 --json
Recovery rejects later conflicting edits. It restores the recorded manifest and lockfile states; it cannot undo database writes, network calls, provider effects or other external effects of application tests. It does not deploy either state.
Privacy remains an application decision
The preview privacy guide documents the explicit local source selection and authenticated SaaS installation contracts. Do not enable an age restriction merely because the framework offers one. Select a server-owned policy for the actual action and accept declarations only where that policy permits their assurance level. Production rejects unsupported stronger methods and process-local replay storage.
Consent-store files, replay databases, encryption/signing keys, subject/tenant
binding and retention/restore procedures need their own deployment plan.
Shared-local SQLite consent is not a multi-host consensus store; the optional
consent-postgres store shares consent through one authoritative PostgreSQL
database. The profile
export remains available independently of optional-consent configuration, but it
only exports the authenticated account fields supported by its adapter.
Evidence and remaining acceptance
update_project_cli exercises distinct 12.1.0 and 13 packages through real Cargo
resolution, prepare/verify/review/apply/recover, changed inputs and wrong approval
digests. Those intentionally tiny packages prove the updater protocol, not the
framework’s runtime API compatibility. The separate generated-starter suites
compile actual framework source and execute application/authentication tests;
the age/privacy suites also exercise both opt-in installation orders.
Final release admission still requires the combined exact-commit workspace and feature/consumer matrix, strict Clippy and formatting, package/native artifact rehearsals, the required security/fuzz workflows, and this inventory updated for all admitted changes. Application sandbox/provider journeys and deployment smoke tests remain distinct from framework CI. See the SST and delivery plan.
Generated project instructions and context
Since v13, cargo rullst new and cargo rullst generate:ai-context write a
bounded project inventory and create an application AGENTS.md when none
exists; existing instructions are preserved. Point your own coding assistant at
AGENTS.md and .llms.txt. This is a
source-navigation aid; the map does not establish that a feature is
implemented, a test passed, a provider is configured or a deployment is secure.
cargo rullst generate:ai-context
cargo rullst generate:ai-context --check
The first command writes .llms.txt and .rullst/context-map.json. The second
recomputes their expected content without writing and returns a nonzero exit
status if either output is missing, modified or stale. New projects receive the
same inventory and instructions. make:controller, make:model,
make:middleware, make:worker, make:island, auth, make:billing,
make:cors, make:jwt, make:privacy, make:age-gate and make:oauth
refresh the map after they write; a refresh failure is reported while
preserving the completed scaffold.
The documentation site also publishes llms.txt and llms-full.txt at
https://rullst.github.io/Rullst/llms.txt and
https://rullst.github.io/Rullst/llms-full.txt: an index of the framework’s
book and its full text, for assistants that need framework documentation rather
than your application’s inventory.
The versioned rullst.project-context.v1 map contains the project and dependency
names, normalized dependency requirements, requested dependency features,
declared Cargo feature names,
configuration key names, source-file paths/roles/sizes and an input SHA-256.
Dependency requirements inherited from a workspace or omitted for path/git
sources are identified explicitly. It does not resolve Cargo’s complete feature
graph or inspect external workspace members. It scans the selected Cargo root
and its src directory; generate a separate map at each application root.
The fingerprint covers Rust source bytes and the metadata actually inventoried. Configuration values and dependency URLs are excluded even from this digest. Changing only those values therefore does not make the inventory stale. The fingerprint is an aid to detecting source changes, not a signed attestation.
Boundaries
| Input or output | Limit or behavior |
|---|---|
Cargo.toml, optional Rullst.toml, optional .env.example | At most 256 KiB each; errors do not echo their contents. |
.env.example | Single-line assignments, including quoted values and export; only key names are recorded. Multiline declarations fail before continuation content can become a key. No variable interpolation occurs. |
| Rust sources | Regular .rs files below src; at most 512 files, 1 MiB each and 8 MiB combined. |
| Traversal | At most 4,096 observed entries and 16 nested directories; linked/special files, hidden descendants, target, vendor and node_modules are excluded. A linked source root or configuration file fails. |
| Names | Bounded ASCII names and relative paths; unsupported names fail rather than being silently renamed. |
| Outputs | At most 256 KiB each; linked or unrecognized output is rejected before replacement. |
| Project instructions | Existing AGENTS.md is neither read into the map nor replaced. Edit it to add application-specific requirements. |
Source bodies, configuration values, credentials, live .env files, database
files, dependency URLs and absolute workstation paths are not copied into the
outputs. The map does not infer route authorization, model relationships or
provider readiness from filenames. Read the source before changing behavior.
The workspace must be trusted against concurrent adversarial filesystem edits;
ordinary replacement uses the CLI’s preflight and atomic rollback helpers.
Existing applications
Regeneration recognizes the prior CLI’s generated .llms.txt header and replaces
that source-concatenation format with the inventory. A legacy file larger than
the output budget or an unrecognized manually maintained file is refused; move
it aside yourself after reviewing what it contains. Regeneration updates only
recognized generated outputs and never replaces project instructions.
Current local fixtures exercise deterministic output, stale checks, malformed configuration, private-value omission, source limits, links, output conflicts, legacy migration and real project/scaffold commands. Combined hosted and installed-archive acceptance passed in PR #221; final release evidence is tracked separately in the v13 delivery plan.
Schema-first Rust and TypeScript APIs
cargo rullst generate:api is new in v13 (first published in 13.0.0-alpha.1). It consumes one explicit
OpenAPI 3.1 JSON document and generates Rust DTOs/operation codecs, a TypeScript
client and a canonical OpenAPI copy. It does not infer request or response types
from a route handler, install dependencies or mount routes. The earlier
generate:openapi and generate:ts scanning commands remain separate discovery
helpers with placeholder/unchecked types.
Generate and check
Use the v13 CLI (cargo install cargo-rullst --version =13.0.0-alpha.4 --locked;
see Getting started). From the project root:
cargo rullst generate:api --schema api/profile.json --output src/profile_api
cargo rullst generate:api --schema api/profile.json --output src/profile_api --check
The output directory contains contract.rs, client.ts and openapi.json.
--check compares all three without writing anything. A missing, changed or stale
output fails. Regeneration preflights all files, preserves unrelated files and
refuses linked, special or unrecognized existing outputs. Keep application
handlers outside these generated files. The schema digest records input identity;
it does not authenticate the schema or prove deployment compatibility. Generation
assumes a trusted project directory without concurrent adversarial filesystem edits.
This minimal input uses the explicit rullst.api.v1 profile:
{
"openapi": "3.1.1",
"x-rullst-profile": "rullst.api.v1",
"info": {"title": "Profile API", "version": "1"},
"security": [{"bearerAuth": []}],
"components": {
"securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer"}},
"schemas": {
"Profile": {
"type": "object", "additionalProperties": false,
"required": ["display_name", "note"],
"properties": {
"display_name": {"type": "string", "minLength": 1, "maxLength": 80},
"note": {"type": ["string", "null"], "maxLength": 200}
}
},
"Failure": {
"type": "object", "additionalProperties": false,
"required": ["code"],
"properties": {"code": {"type": "string", "maxLength": 80}}
}
}
},
"paths": {
"/profiles/{owner}": {
"get": {
"operationId": "read_profile",
"parameters": [{
"name": "owner", "in": "path", "required": true,
"schema": {"type": "string", "minLength": 1, "maxLength": 80}
}],
"responses": {
"200": {"description": "Own profile", "content": {
"application/json": {"schema": {"$ref": "#/components/schemas/Profile"}}
}},
"401": {"description": "Unauthenticated", "content": {
"application/json": {"schema": {"$ref": "#/components/schemas/Failure"}}
}},
"403": {"description": "Forbidden", "content": {
"application/json": {"schema": {"$ref": "#/components/schemas/Failure"}}
}}
}
}
}
}
}
Server wiring and authorization
Add direct serde (with derive), serde_json and rullst-security
dependencies; pin rullst-security to the same exact version as rullst
(rullst-security = "=13.0.0-alpha.4"), because contract.rs uses its
schema_guard::JsonSchemaPolicy. Import contract.rs as an application
module and construct Contract::new() once during startup; handle configuration
errors before serving traffic. Each op_<operationId> module provides METHOD,
PATH, a typed Params, decode_params and typed response variants plus
encode_response. Body operations also expose Request and decode_request.
Authenticate the bearer token and check tenant/resource ownership in the handler.
Pass decoded path/query pairs to decode_params, retaining duplicate parameters
so the codec can reject them. Path fields use p_ and query fields q_ prefixes.
For a body operation, enforce application/json and the 64 KiB transport limit
before reading, then pass the bytes to decode_request. Encode every response
through the operation codec, set its returned HTTP status and JSON content type,
and retain the application’s secure headers, WAF, CSRF policy and rate limits.
Never derive the authenticated user from a submitted path/body owner value.
Serde derives are useful for application composition, but using them directly
bypasses schema, presence and size checks. The generated codecs are the wire
boundary. An optional field is omitted when its Option is None; a required
nullable field serializes None as JSON null. Missing required nullable fields
and null optional non-nullable fields are rejected before deserialization.
Client use
Compile client.ts with TypeScript’s --strict --exactOptionalPropertyTypes,
--target ES2022 and --lib ES2022,DOM. The client uses Fetch, Web Streams,
TextEncoder/TextDecoder and AbortSignal.any; this increment is exercised on
Node 24, not a claim of acceptance on every browser or native WebView.
const client = new ContractClient('https://api.example.com', () => sessionToken);
const response = await client.call_read_profile({p_owner: authenticatedUserId});
if (response.status === 200) {
console.log(response.body.display_name);
} else {
console.log(response.status, response.body.code);
}
The base URL must be an HTTP(S) origin without credentials, path, query or
fragment. Supply the current application-owned token through the callback; it
is not persisted by the client. Pass an optional AbortSignal to each operation.
The client encodes path/query values, refuses redirects, omits cookies, requests
no-store/no-referrer behavior and bounds each operation to ten seconds including
credential lookup. Callbacks still own cancellation of their own background work.
Declared error statuses return their typed body union. Unknown statuses, malformed
JSON, duplicate keys, invalid UTF-8, oversized responses and schema mismatches
raise a fixed ContractError without tokens, upstream bodies or server diagnostics.
Supported profile and limits
The profile follows the OpenAPI 3.1 schema model and preserves required versus optional object properties. Enable strict null checking so those distinctions remain visible to the consumer compiler.
| Area | Initial supported contract |
|---|---|
| Document | JSON; OpenAPI 3.1.0/3.1.1/3.1.2; explicit rullst.api.v1; global HTTP bearer scheme. No external files/network resolution. |
| Shapes | Closed named objects, local acyclic object references, bounded strings/arrays, booleans and safe integers. Unknown keywords fail. |
| Presence | Required nullable scalar/array fields and optional non-nullable fields. Optional nullable fields and nullable references are unsupported. |
| Strings/arrays | Required maxLength ≤ 4096 code points or maxItems ≤ 128; optional valid minimum. Invalid Unicode surrogates fail. |
| Integers | Explicit minimum/maximum within ±9,007,199,254,740,991. Numeric tokens ≤128 characters and exponent within ±128; exact decimal/exponent forms must represent an integer, without fractional rounding. |
| Parameters | Explicit path strings with minLength ≥1 and query strings/integers/booleans; explicit required. Empty/dot path segments, unknown/duplicate parameters, noncanonical query integers and null query values fail. |
| Operations | GET/DELETE without body; POST/PUT/PATCH with required JSON object body; explicit JSON success, 401 and 403 responses. No wildcard statuses, redirects, empty-body 204/205 or streaming. |
| Names | Components: uppercase-first ASCII alphanumeric, ≤64 characters. Fields/operation IDs: lowercase-first ASCII letters, digits and underscores, ≤40; reserved collision names fail. |
| Generation | 128 KiB source, depth24/8192 source nodes, 32 schemas, 32 paths/operations, 32 properties/object, 16 parameters/responses, reference depth8; 512 KiB per output. |
| Wire | 64 KiB per received/sent JSON body, depth24 and 8192 value nodes. Exact integer normalization has a separate bounded scratch buffer. |
Formats, patterns, defaults, enums, open maps, inline objects, recursive schemas, polymorphism, arbitrary numeric wire values, file uploads, headers/cookie parameters and authentication schemes beyond this profile are rejected. Extend the source profile and its compiled consumer tests before adding another supported shape.
Local acceptance compiles generated Rust with strict Clippy and TypeScript 5.9.3 from a pinned lockfile, then executes the client against a disposable Rust HTTP server. It covers Unicode, optional/null semantics, input/output validation, query duplicates, cross-owner/unauthenticated denial and transport faults. The test’s fixed identities are fixtures, not a production authentication service. The combined source passed hosted and installed-archive acceptance in PR #221. The final release campaign and deployment-specific browser acceptance remain separate requirements.
Translations and Locales
Since 13.0.0-alpha.2, rullst::i18n translates an application’s interface
with catalogs checked at compile time, chooses each visitor’s locale from a
cookie or Accept-Language, and switches locale with a CSRF-protected form.
It is part of Core and needs no Cargo feature.
- Catalogs: one TOML file per locale, loaded by
rullst::i18n::catalog!when the crate compiles. A key missing from any locale, a key used in code that no catalog has, or placeholders that differ between locales fail the build. t!: returns plain text thathtml!escapes. Only keys ending in_htmlare trusted HTML.- Plurals: CLDR’s cardinal rules for 18 languages (Portuguese has
separate Brazilian and European rules), with every form (
zero,one,two,few,many,other) the language needs. - Negotiation: the
rullst_localecookie, thenAccept-Language, then your default; the chosen locale also feeds<html lang>. - Formatting: numbers, percentages, money and dates in the locale’s conventions, with CLDR data for 20 locales.
There is no make:i18n generator yet: the setup below is a few small files.
Set up
1. Write one catalog per locale
Create locales/ next to Cargo.toml, with one file per locale named by its
tag (language[-Script][-REGION] in canonical case: en, pt-BR, zh-Hant,
es-419). Nested tables form dotted keys.
# locales/en.toml
[nav]
home = "Home"
language = "Language"
[dashboard]
title = "Dashboard"
greeting = "Hello, {name}!"
[inbox.unread]
one = "You have one unread message"
other = "You have {count} unread messages"
[legal]
terms_html = "I accept the <a href=\"/terms\">terms</a>, {name}"
# locales/pt-BR.toml
[nav]
home = "Início"
language = "Idioma"
[dashboard]
title = "Painel"
greeting = "Olá, {name}!"
[inbox.unread]
one = "Você tem {count} mensagem não lida"
other = "Você tem {count} mensagens não lidas"
[legal]
terms_html = "Aceito os <a href=\"/terms\">termos</a>, {name}"
2. Load them in a module
// src/i18n.rs
rullst::i18n::catalog!(default = "en");
Declare it with mod i18n; in main.rs. catalog! defines two items in
that module: LOCALES, the supported locales, and the t! macro, which
other modules import with use crate::i18n::t;. Use dir = "path" for
another directory (relative to Cargo.toml).
catalog! tracks the files it read, so editing one rebuilds the crate.
Stable Rust gives a macro no way to watch a directory, so add a build.rs
next to Cargo.toml to rebuild when a locale file is added or removed:
// build.rs
fn main() {
// Use the same directory as `catalog!(dir = …)`.
println!("cargo::rerun-if-changed=locales");
}
Cargo rescans the directory before each build. Without this file, a new locale appears only after another change to the crate.
3. Negotiate, translate and switch
use crate::i18n::{LOCALES, t};
use rullst::i18n::Locale;
use rullst::security::CsrfToken;
use rullst::server::Extension;
use rullst::{HtmxRequest, html, render_page_with_lang};
async fn dashboard(
locale: Locale,
htmx: HtmxRequest,
Extension(csrf): Extension<CsrfToken>,
) -> rullst::response::Html<String> {
let switcher = html! {
<form method="post" action="/locale">
<input type="hidden" name="_token" value={csrf.as_str()} />
<input type="hidden" name="next" value="/" />
<label>{t!(locale, "nav.language")}
<select name="locale">
@for option in LOCALES.iter() {
<option value={option.tag()} selected?={option == locale}>{option.tag()}</option>
}
</select>
</label>
<button>"OK"</button>
</form>
};
let body = html! {
{switcher}
<h1>{t!(locale, "dashboard.greeting", name = "Ana")}</h1>
<p>{t!(locale, "inbox.unread", count = 3)}</p>
};
render_page_with_lang(&htmx, locale.tag(), &t!(locale, "dashboard.title"), body)
}
let app = rullst::Router::new()
.route("/", rullst::web::axum::routing::get(dashboard))
.route("/locale", LOCALES.switch_route())
.layer(LOCALES.layer())
// `Server` adds the CSRF middleware only in staging and production.
.layer(rullst::server::from_fn(rullst::security::csrf_middleware));
LOCALES.layer() negotiates every request’s locale and the Locale
extractor reads it; a handler that extracts Locale on a route without the
layer answers 500 naming the missing layer. Pass locale.tag() to
render_page_with_lang so <html lang> matches the text.
The switch route is a form POST, so the CSRF middleware checks its
_token field like any other form. Server installs that middleware only in
staging and production; compose csrf_middleware on the router as above, as
the starters do, or Extension<CsrfToken> answers 500 in development.
rullst-core/tests/i18n_catalog.rs compiles this handler’s language menu and
runs catalogs like the ones above end to end, including the switch behind the
CSRF middleware; the rullst::i18n API documentation runs the rest as a
doctest.
Catalog format
| Form | Meaning |
|---|---|
key = "text" | A message. Keys use ASCII letters, digits, _ and -; tables nest them with .. |
{name} | A placeholder: t!(locale, "key", name = value). Any Display value. Names are Rust identifiers that are not keywords. |
{{ and }} | A literal { or }. |
a table of CLDR categories with other | A plural message, called with count = n (any integer type or a Decimal; the absolute value is used). |
a key ending in _html | Trusted HTML from the catalog. t! returns Markup, and its arguments are HTML-escaped. |
t! returns Cow<'static, str>: a message without placeholders is borrowed
from the binary, others are formatted. Text from a catalog is never trusted
as HTML unless its key ends in _html, so "a < b" renders as a < b.
Plural rules
Rullst selects the form with CLDR’s cardinal plural rules (the same rules ICU and browsers use, checked against CLDR’s sample numbers). A plural message defines exactly the forms its locale’s language uses:
| Languages | Forms | Examples |
|---|---|---|
ja, ko, zh | other | every count |
de, en, nl | one, other | one: 1 (but not 1.0) |
tr | one, other | one: 1 and 1.0 |
es, it, pt-PT | one, other, optional many | one: 1; many: 1000000, 2000000 |
fr, pt, pt-BR | one, other, optional many | one: 0, 1, 1.5; many: 1000000 |
cs | one, few, many, other | few: 2–4; many: decimals |
pl | one, few, many, other | few: 2–4, 22–24; many: 0, 5–21, 25 |
ru, uk | one, few, many, other | one: 1, 21; few: 2–4, 22; many: 0, 5–20; other: decimals |
ro | one, few, other | few: 0, 2–19, 101; other: 20, 100 |
he | one, two, other | two: 2 |
ar | zero, one, two, few, many, other | few: 3–10, 103; many: 11–99 |
Regions and scripts use their language’s rule (fr-CA, es-419, zh-Hant),
except Portuguese: pt-PT has its own rule and other Portuguese regions are
not supported. many in Spanish, French, Italian and Portuguese only
applies to exact millions (“1 million de messages”); when a message leaves it
out, other is used. A missing form is a compile error that names it:
error: locales/ru.toml: plural message `inbox.unread` needs a `few` form for `ru`, which uses `one`, `few`, `many` and `other`
# locales/ru.toml
[inbox.unread]
one = "У вас {count} непрочитанное сообщение"
few = "У вас {count} непрочитанных сообщения"
many = "У вас {count} непрочитанных сообщений"
other = "У вас {count} непрочитанного сообщения"
Visible fraction digits count, as in CLDR: pass a rullst::i18n::Decimal
(Decimal::new(15, 1) is 1.5, "1.0".parse() keeps its zero) to select the
form of a decimal. {count} displays the Decimal as plain ASCII
(1.5); pass a separately formatted value for display when the locale
writes decimals differently. Without a catalog, locale.plural_category(n)
returns the PluralCategory.
#![allow(unused)]
fn main() {
use rullst::i18n::{Decimal, Locales, PluralCategory};
let locales = Locales::new(&["pl", "ar", "en"], "en")?;
let pl = locales.get("pl").unwrap();
assert_eq!(pl.plural_category(22), Some(PluralCategory::Few));
assert_eq!(pl.plural_category(25), Some(PluralCategory::Many));
assert_eq!(pl.plural_category(Decimal::new(25, 1)), Some(PluralCategory::Other));
let ar = locales.get("ar").unwrap();
assert_eq!(ar.plural_category(0), Some(PluralCategory::Zero));
assert_eq!(ar.plural_category(111), Some(PluralCategory::Many));
Ok::<(), rullst::i18n::LocaleError>(())
}
Format numbers, money and dates
A Locale formats values with CLDR’s conventions (CLDR 48 data compiled
into Rullst, no runtime files or network): decimal and grouping separators,
the minus sign, percent and currency placement, and the locale’s date and
time patterns with its month and weekday names.
#![allow(unused)]
fn main() {
use chrono::NaiveDate;
use rullst::i18n::{Currency, DateStyle, Decimal, Locales, TimeStyle};
let locales = Locales::new(&["en-US", "pt-BR", "de-DE", "fr-FR"], "en-US")?;
let [en, pt, de, fr] = ["en-US", "pt-BR", "de-DE", "fr-FR"].map(|tag| locales.get(tag).unwrap());
// Money is given in integer minor units (cents), never as a float.
assert_eq!(pt.format_currency(123_456, Currency::BRL), "R$\u{a0}1.234,56");
assert_eq!(en.format_currency(123_456, Currency::USD), "$1,234.56");
assert_eq!(de.format_currency(-123_456, Currency::EUR), "-1.234,56\u{a0}€");
assert_eq!(en.format_currency(1_234, Currency::JPY), "¥1,234");
let franc = Currency::new("CHF", 2).unwrap();
assert_eq!(de.format_currency(1_250, franc), "12,50\u{a0}CHF");
// Numbers show every digit of a `Decimal`; round explicitly.
assert_eq!(fr.format_number(1_234_567), "1\u{202f}234\u{202f}567");
assert_eq!(pt.format_number(Decimal::new(-12_345, 1)), "-1.234,5");
let ratio = Decimal::new(256, 3); // 0.256
assert_eq!(en.format_percent(ratio), "25.6%");
assert_eq!(en.format_percent(ratio.round_half_even(2)), "26%");
assert_eq!(fr.format_percent(ratio.round_half_even(2)), "26\u{a0}%");
// Dates and times, from any chrono date or time.
let at = NaiveDate::from_ymd_opt(2026, 10, 22)
.and_then(|date| date.and_hms_opt(15, 45, 0))
.unwrap();
assert_eq!(pt.format_date(&at, DateStyle::Short), "22/10/2026");
assert_eq!(en.format_date(&at, DateStyle::Short), "10/22/26");
assert_eq!(en.format_date(&at, DateStyle::Numeric), "10/22/2026");
assert_eq!(de.format_date(&at, DateStyle::Medium), "22.10.2026");
assert_eq!(pt.format_date(&at, DateStyle::Full), "quinta-feira, 22 de outubro de 2026");
assert_eq!(
en.format_datetime(&at, DateStyle::Long, TimeStyle::Short),
"October 22, 2026 at 3:45\u{202f}PM"
);
assert_eq!(pt.format_time(&at, TimeStyle::Medium), "15:45:00");
Ok::<(), rullst::i18n::LocaleError>(())
}
In a view, interpolate the result like any other text:
<td>{locale.format_currency(order.total_cents, Currency::BRL)}</td>.
| Method | Input | Example (pt-BR) |
|---|---|---|
format_number(value) | any integer or a Decimal | 1.234,5 |
format_percent(ratio) | a ratio (0.25 is 25%) | 25% |
format_currency(minor_units, currency) | i64 minor units and a Currency | R$ 1.234,56 |
format_date(&date, style) | any chrono::Datelike | 22/10/2026 |
format_time(&time, style) | any chrono::Timelike | 15:45 |
format_datetime(&value, date_style, time_style) | both | 22/10/2026, 15:45 |
- Locales with data:
ar,cs,de,en(United States),en-GB,es(Spain),fr,he,it,ja,ko,nl,pl,pt(Brazil),pt-PT,ro,ru,tr,ukandzh(Simplified). Another tag uses its language’s data (es-MXwrites likees,fr-CAlikefr,zh-Hantlikezh), and a language without data usesen’s;locale.format_data_tag()tells which. All of them write Latin digits and group by thousands (Indian lakh grouping is not covered;hihas no data). - Styles:
DateStyle::Short,Medium,LongandFullare CLDR’s date formats (Shortmay use a two-digit year, asen’s10/22/26does);DateStyle::Numericis CLDR’s day-month-year in digits, what browsers show by default.TimeStyle::ShortandMediumomit the time zone, so format aDateTimealready converted to the visitor’s zone. - Money:
Currency::BRL,USD,EUR,GBPandJPYuse each locale’s symbol (R$,US$,€,£,¥) and spacing;Currency::new(code, minor_digits)writes any other ISO code as letters. Amounts are integer minor units (i64), so no floating-point rounding happens. - Decimals: a
Decimalkeeps its fraction digits (Decimal::new(150, 2)is1.50;"1.50".parse()also works).round_half_even(CLDR and ICU’s default) andround_half_up(what browsers do by default) choose the digits shown. - Spaces: CLDR writes U+202F NARROW NO-BREAK SPACE in places such as
frgrouping and beforeen’sAM/PM, and U+00A0 NO-BREAK SPACE in others such aspt-BR’sR$ 1.234,56. Browsers write a plain space in dates and times where CLDR has U+202F.
What fails the build
- A key present in one locale and missing from another (both directions).
- A key whose placeholders differ between locales, or that is plural in one locale and single in another.
- An unbalanced
{or}, or an invalid placeholder name. - A plural message without a form its language needs (the error names it), with a form the language never selects, or in a language without a rule.
- A file name that is not a canonical tag (
pt-br.tomlasks forpt-BR.toml), and adefaultwithout a file. - In
t!: an unknown key (with a suggestion for a near miss), a missing or unexpected argument, a repeated argument, and a key that is not a string literal.
error: unknown translation key `nav.hone`; did you mean `nav.home`?
--> src/pages.rs:19:20
|
19 | let _ = t!(locale, "nav.hone");
| ^^^^^^^^^^
Keys must be literals so they can be checked. To translate a value chosen at
runtime (such as a status stored in the database), match it to keys.
Editing a catalog file rebuilds the crate, and with the build.rs from
step 2 adding or removing a locale file does too.
Negotiation
Locales::negotiate (used by the layer) picks the first of:
- the
rullst_localecookie, when it names a supported locale (ignoring case); - the best match in the first
Accept-Languageheader: ranges sorted by theirqweight (RFC 9110), keeping the header order on ties; a range matches a supported tag exactly or by language (pt-PTandptmatchpt-BR, preferring the default locale when it shares the language);*matches the default;q=0and malformed weights are skipped. Values over 4 KiB are ignored, and only the first 32 ranges (each up to 35 bytes) are read; - the default locale.
LOCALES.get(tag), LOCALES.iter() and LOCALES.default_locale() give the
supported locales, for example to build a language menu.
The switch route
LOCALES.switch_route() accepts a URL-encoded form with locale and an
optional next:
- an unsupported
localeor a malformed form answers400, a body over 4 KiB answers413, and the cookie is not changed; - otherwise it sets
rullst_locale=<tag>; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly, plusSecurein staging and production (the same rule as the CSRF cookie); - it redirects with
303 See Othertonextwhennextis a local path (it starts with a single/, has no\and only printable ASCII, up to 2 KiB), and to/otherwise, so it cannot redirect to another site; - an htmx request (
HX-Request: true) gets204withHX-Redirectinstead, so the whole page reloads in the new language.
Without a catalog
An application that keeps its own message tables can still use the
negotiation, the layer and the switch route with Locales::new:
#![allow(unused)]
fn main() {
use rullst::i18n::Locales;
use std::sync::OnceLock;
static LOCALES: OnceLock<Locales> = OnceLock::new();
let locales = Locales::new(&["en", "pt-BR"], "en")?;
assert_eq!(
locales.accept_language("pt-PT, en;q=0.8").map(|locale| locale.tag()),
Some("pt-BR")
);
// `layer()` and `switch_route()` borrow the list for the whole program; a
// `static` holds it without leaking memory.
let locales: &'static Locales = LOCALES.get_or_init(|| locales);
let _layer = locales.layer();
Ok::<(), rullst::i18n::LocaleError>(())
}
Account emails from rullst-mail keep their own languages; choose one from
the request with MailLocale::from_preference(locale.tag()).
Limits
- Cardinal plural rules only for the languages above; no ordinal rules (“1st”, “2nd”).
- Language matching ignores scripts and regions (
zh-Hantmatcheszh-Hanswhen no better tag exists). - Formatting covers the 20 locales above, Latin digits and the Gregorian
calendar; no compact (
1.2K), ordinal or spelled-out numbers, time zone names or relative times (“3 days ago”). - Catalogs are compiled in; there is no runtime catalog loading.
Active sessions and remote logout
The v13 candidate extends rullst-auth::recovery::SqlRecoveryStore with a bounded
session inventory, selective sibling logout and logout of all other sessions.
It uses the existing authoritative account registry and opaque bearer sessions;
it does not add another crate. Hosted workspace/platform and extracted-package source admission passed in PR #236. Final release admission remains separate.
Choose recovery-sqlite or recovery-postgres on Auth. The facade exposes
auth-sessions-sqlite and auth-sessions-postgres without enabling Mail or the
general ORM. Existing account-mail-* consumers gain the same APIs after their
explicit schema migration.
Adoption and isolation
Follow the account-registry adoption contract for keys,
password verification, cookies and account migration. Run migrate() during a
controlled deployment before using the new library: it adds a session-details
table and an expiry index. Existing opaque sessions continue working and report
unknown creation/label values if they predate those details. Encrypted-only
rullst_session cookies are revoked through their own per-user
session epoch, which ends
all of a user’s cookies at once, and independently issued JWTs through their
own revocation store.
Applications generated by cargo rullst auth and the SaaS starter (and the LMS example) use
those epoch cookies. Their password change stores the new hash and increments
the epoch in one UPDATE, then gives the browser that made the change a cookie
with the new epoch, so only that browser stays signed in. Their password reset
increments it in the transaction that consumes the single-use reset token and
signs in nobody, so every device must sign in again. See
Tutorial 11.
Stores are application/tenant boundaries selected by trusted server
configuration, with separate databases and keys. A browser cannot choose a
database, account subject or tenant by supplying an identifier. Every protected
request calls verify_session against that same authoritative store. PostgreSQL
can serve multiple hosts; SQLite requires processes sharing the same local file
and does not provide multi-host replication. SqlRecoveryStore::connect requires
certificate- and hostname-verified TLS (sslmode=verify-full) for every remote
PostgreSQL host and accepts only connection/TLS query options, as email login and
API tokens do.
Management methods authenticate the supplied current session again inside a
serialized SQL transaction. They never accept a target account. The host still
checks current tenant membership and protects its routes with CSRF, the security
baseline, ingress limits and Cache-Control: no-store. Use trusted server time,
never browser timestamps, and maintain a reliable deployment clock.
Inventory and logout
Each account has at most 20 sessions. active_sessions returns only unexpired
sessions matching the current account version, with a management ID, expiry,
optional creation time, optional
display label and a current-session marker. The ID is a purpose-separated keyed
digest and cannot authenticate as the session. Debug output hides IDs and labels.
#![allow(unused)]
fn main() {
#[cfg(feature = "auth-sessions-sqlite")]
async fn sign_out_other_devices(
store: &rullst::auth::recovery::SqlRecoveryStore,
current_cookie: &str,
trusted_now: u64,
) -> Result<usize, rullst::auth::recovery::RecoveryError> {
// Host middleware has already authorized this account/tenant and checked CSRF.
store.revoke_other_sessions(current_cookie, trusted_now).await
}
}
revoke_other_session(current_cookie, &SessionId, trusted_now) removes one
active sibling. A missing, expired, foreign-account or current-session target is
rejected. revoke_other_sessions removes all siblings and advances the account
session version, preserving the current bearer token. A password-authentication
proof obtained before that operation can no longer mint a session; authenticate
again before creating a later session. Repeating logout of siblings is safe.
Use the existing revoke_session(current_cookie) for current-device logout.
create_session_with_label accepts a SessionLabel of 1–80 UTF-8 bytes without
control characters or surrounding whitespace. Labels are explicit display text,
not verified device identity. Escape them in HTML. No IP addresses, raw user
agents, fingerprints or activity history are collected. The ordinary
create_session records creation time without a label.
Expiration, retention and failures
Expired sessions are rejected at their exclusive expiry boundary. Time passing
does not physically delete rows. Creating a session prunes that account’s expired sessions;
schedule the operator-owned purge_expired_sessions(trusted_now, limit) to remove
1–100 expired sessions per transaction, including inactive accounts. Metadata is
deleted atomically with sessions during retention, logout and password reset.
Operator database/backups/encryption and retention scheduling remain deployment
responsibilities; do not expose this maintenance API as a public endpoint.
Session creation, verification, management and retention operations have a ten-second database-operation deadline. Storage failures remain errors rather than authentication success; inventory also rejects out-of-bounds metadata. A timeout or lost commit acknowledgement does not prove that a write rolled back: query authoritative state before reporting its outcome. Already-authorized in-flight requests are not recalled by logout; long-lived WebSockets and other identity systems need their own revalidation policy.
Automated acceptance
Tests exercise account/store separation, metadata migration, expiry, password recovery, selective/all-other logout, transaction rollback, concurrent logout, retention and denied access after storage failure. Real HTTP fixtures use two independent application processes, the Core security baseline and tenant checks; they prove that a completed logout is visible on the other process’s next request and persists after application restart.
The digest-pinned disposable PostgreSQL journey additionally restarts the actual database and checks retained/revoked sessions afterwards. A held database lock tests the operation deadline. The archive consumer exercises the public facade from extracted packages. These are automated contract checks, not evidence of a particular production identity migration, failover topology or human-tested UI.
Optional email login (v13 candidate)
rullst-auth features email-login-sqlite and email-login-postgres expose
recovery::email_login::EmailLoginService. The umbrella features are
auth-email-login-sqlite and auth-email-login-postgres. They are opt-in and
reuse the authoritative SqlRecoveryStore account and opaque-session registry.
They do not enable JWT, OAuth or email login on existing accounts automatically.
Combined hosted source/package/coverage admission passed in
PR #239;
final release admission remains separate.
Account and application boundaries
Create an immutable EmailLoginConfig with a server-owned application/tenant
namespace, HTTPS landing URL, fixed local destination and capacity (1–100,000
account policies per namespace). Query/fragment/user-info in the landing URL and
encoded/authority/traversal syntax in the destination are rejected. The explicit
development constructor allows HTTP only on localhost/loopback.
Use initialize during deployment and connect during normal startup. Access
accounts() to register/authenticate accounts in this registry. Enable a policy
with set_account_enabled only after recent password authentication plus the
application’s tenant and MFA policy. The proof must come from this service’s
account registry instance and current account epoch. Disabled accounts retain
their policy/revision and count toward capacity. An administrator-provided
namespace is a storage boundary, not proof that a caller belongs to a tenant.
Resolve membership, roles and resource ownership on each authorized request.
Email access is a single factor. Do not enable this route as an alternative that bypasses required MFA, passkeys or a stronger identity assurance policy. This increment does not implement enrollment UX, account linking, invitation-based registration, step-up MFA, verified age or guardian authorization.
Request, delivery and deliberate confirmation
- Generate an independent
BrowserBindingon the server. Keep it in a dedicatedSecure; HttpOnly; SameSite=Laxcookie, scoped to the application, with a maximum age of 15 minutes. Protect the request endpoint with CSRF and ingress limits. Never accept a browser binding chosen through a link or request body. - Call
request_loginwith the email, binding and trusted server clock. Known, unknown, disabled, suppressed and throttled accounts receive the sameLoginRequestAccepted. Each namespace allows 120 requests per minute and each account three per 15 minutes. A 250 ms minimum acknowledgement delay mitigates trivial enumeration; it is not a constant-time guarantee under database contention or faults. Rate-limit at ingress independently. - Issuance creates a distinct random 256-bit emailed token, stores domain-bound HMAC digests of both secrets, binds the account epoch and policy revision, and atomically queues an AES-256-GCM encrypted notice. A new accepted request replaces the prior link and pending/leased notice. Links expire after exactly 15 minutes. Account opt-out, password recovery or another account-epoch change invalidates the pending link. An account has one pending link and one request budget, whichever browser asks. Anyone who knows an enrolled address can therefore replace the owner’s pending link (the new link only works in the requester’s browser) and spend the three requests of each window, blocking email login for that account at about 12 requests per hour. This bounded budget is what prevents mail flooding; a per-browser budget would not, because browser bindings are free to mint. Email login is an optional route: keep password login and recovery available, apply per-client and per-target ingress limits, and let users disable email login if it is abused against them.
- A trusted worker calls
claim_notice, constructs MailAccountEvent::EmailLoginAtwithActionLink, the server-configured origin andexpires_at, then sends with the stabledelivery_id. Use the recorded locale, with explicit fallback. Callcomplete_noticeorfail_noticewith a redacted delivery outcome. Claims last at most 60 seconds, allow six attempts and use bounded backoff. Delivery is at-least-once; transports without idempotency may send duplicates. A bounce or complaint suppresses the account’s future transactional delivery. - The landing GET/HEAD only renders confirmation. It never consumes the
token or creates a session. An explicit CSRF-protected POST passes the emailed
token and cookie binding to
redeem. The initiating browser is required; opening the email on another device requires starting a new request there. Never redeem automatically in JavaScript or on page load. Only a failed token lookup (malformed, unknown, expired, replayed or wrong-browser) is charged, to the presented browser binding. After 60 such failures within 60 seconds, that binding receivesRecoveryError::Throttleduntil its window passes, before any database work, even for a valid link. Other bindings and valid redemptions are never charged or blocked, so one client cannot lock other users out of redemption (request replacement and the per-account request budget are described in step 3). The budget is process-local, keyed by an HMAC digest of the binding and bounded to 10,000 recent bindings. It is not a global brute-force bound: an attacker can rotate bindings, and a multi-host deployment keeps one budget per service instance, so ingress limits remain required. MapThrottledto a generic “try again later” response. - Redemption atomically consumes the link and creates a one-hour opaque session
using the existing account epoch, 20-session limit and inventory/revocation
machinery. Store it in a
Secure; HttpOnly; SameSitecookie, clear the browser binding, and redirect only toEmailLoginSession::destination(). Verify the session and authorization on every request. Logout and account revocation remain authoritative across instances sharing the database.
Use the framework security baseline (CSRF, WAF and secure headers), bounded
request bodies, Cache-Control: no-store and Referrer-Policy: no-referrer on
all landing and authentication responses, including errors. Do not load
third-party scripts, analytics, fonts or images on the landing page. Redact
query strings, cookies and mail bodies before request logging or tracing. Never
serialize the returned session/delivery capabilities into application events.
The test-only /fixture/mail route in the HTTP acceptance harness substitutes
an inbox; it must never be mounted in a deployed application.
The Mail template has deterministic English, Brazilian Portuguese and Spanish HTML/text, absolute expiry, a same-browser confirmation instruction and no tracking pixel. It neither generates tokens nor changes authentication state.
Storage, cancellation and operations
SQLite uses one private connection per instance, WAL, FULL synchronization and
one shared local file. It is not a multi-host database. The deployment owns the
trusted directory, file permissions, encryption-at-rest and backup. PostgreSQL
uses a bounded four-connection pool, verified remote TLS, five-second SQL/lock
limits, permanent tables, fsync/full_page_writes and synchronous commit on a
writable primary. Public methods have a ten-second operation timeout (plus the
minimum request delay). All state mutations serialize through the account
registry’s write lock and recheck clock observations. The namespace keeps a
monotonic clock high-water mark: a host whose clock trails it by at most five
seconds, as synchronized hosts do around each whole-second boundary, adopts the
recorded time, while a larger backward step fails with InvalidAction. A lock
wait or skew adoption cannot extend a link’s expiry. An uncertain commit or cancellation never returns a session.
PostgreSQL bootstrap serializes explicit initializers. Normal startup performs
no DDL or missing-namespace repair. Runtime needs SELECT/INSERT/UPDATE/DELETE on
the account/session/outbox and email-login tables in the trusted public schema;
reserve CREATE/ALTER for the deployment role and revoke schema CREATE from
untrusted roles. Use one authoritative writable database, synchronized clocks,
replication fencing and a bounded number of server-configured namespaces.
Configuration/key drift and non-durable PostgreSQL tables fail closed.
Delivered/terminal email ciphertext is deleted immediately. Run purge_expired
from a trusted retention worker even during idle periods; it removes expired
links and notices. Invalidated encrypted notices that were already claimed may
remain until expiry, and in-flight transport work cannot be recalled. Such links
fail redemption. No recipient activity history is recorded by this component.
A stale database restore can revive consumed links, account epochs and revoked sessions. Quiesce authentication, invalidate restored login challenges/outbox and session state, reconcile account epochs and policies, then resume. This adapter does not provide backup anti-rollback, high availability, secret rotation or a production mail delivery SLA.
Automated evidence
The candidate includes SQLite/PostgreSQL independent-pool races, purpose and namespace isolation, browser mismatch, replacement, opt-out/epoch invalidation, expiry/clock rollback, locked-database expiry, cancellation, SQL failure atomicity, bounded retries and suppression. Process tests use fresh executables; the owned PostgreSQL wrapper also restarts the database service. The Chromium HTTP fixture uses the actual framework security baseline and proves scanner GET/HEAD, CSRF rejection, deliberate POST, secure cookies, tenant authorization, replay rejection and logout across separate database pools.
Run the ordinary SQLite contracts with cargo test -p rullst-auth --features email-login-sqlite. Run the real browser/service contracts with
RULLST_EMAIL_LOGIN_BROWSER_TESTS=1 python3 .github/check-auth-recovery-postgres.py.
The wrapper owns a disposable loopback Docker database; it does not use external
accounts. Packaged-distribution acceptance exercises extracted archives through
the facade. These automated fixtures do not establish live mail-provider or
owner-application acceptance.
Scoped application API tokens (v13 candidate)
Auth features api-tokens-sqlite and api-tokens-postgres expose
recovery::api_tokens::ApiTokenService. Facade features are
auth-api-tokens-sqlite and auth-api-tokens-postgres. They reuse authoritative
recovery accounts and do not enable email login, JWT or OAuth. This increment
is an implementation candidate; combined hosted and extracted-package source
admission passed in
PR #239,
and final release admission remains separate.
Create and manage credentials
Configure an immutable server-owned ApiTokenConfig: namespace, exact scope
allowlist, capacity (1–100,000 rows) and maximum lifetime (1 second–30 days).
Scopes are explicit, case-sensitive names such as orders:read: 1–32 distinct
names, each 1–64 lowercase ASCII letters, digits or :._-, starting with a
letter. Wildcards and implied parent scopes are unsupported. Use initialize
as a deployment task and connect for normal startup without DDL or repair.
- Authenticate the account through
service.accounts().authenticate. The resulting proof belongs to this exact registry instance and account epoch. The application must additionally require recent authentication, its MFA policy and current tenant/domain permissions. Intersect requested scopes with those permissions before callingissue; the configured allowlist alone is not an account authorization policy. issueaccepts the proof, approved scopes, a boundedSessionLabel, lifetime and trustedSystemAuthClock. ReturnIssuedApiToken::expose_beareronce over HTTPS withCache-Control: no-store. Store it on the integration side in a secret store, never in URLs, analytics, browser local storage or application logs. Management endpoints retain CSRF, secure headers and ingress limits.inventoryreturns this account’s active metadata and opaque management IDs, never secrets or digests. Each account has at most 20 active credentials per namespace, within the deployment capacity. No IP, user-agent or usage history is collected. Runpurge_expiredeven when issuance is idle.rotaterequires the exact current revision. It keeps ID/scopes/creation time, replaces the secret, increments the revision and optionally renews the expiry within policy. Only one concurrent rotation succeeds. Revoked or expired credentials cannot be rotated. A lost/uncertain rotation response requires authenticated inventory/revocation and a new rotation or separately approved issuance; the service cannot recover the secret.revokeis owner-scoped and idempotent; foreign and absent IDs return false.revoke_allaffects this account in this namespace. Recovery/account-epoch invalidation rejects all older API credentials and management proofs.
Each wire credential contains a versioned rlt1_ prefix, random 256-bit management
ID and independent random 256-bit secret. Only a purpose/namespace-bound HMAC
is persisted. A management ID, provider key or browser-session token cannot be
used as this bearer. Credential/principal Debug output is redacted.
Authenticate each request
Call verify for every request with the route’s required ApiScopes and trusted
server clock. It reads current SQL state, compares the HMAC in constant time,
checks scope inclusion, account epoch and expiry, and fails on storage faults.
Resolve current tenant membership, account roles and resource ownership after
verification. An ApiTokenPrincipal is a snapshot, not an authorization cache
or a credential for issuing more tokens.
For exact mutating machine routes, compose
service.machine_verifier(required_scopes) with
MachineEndpoint::verified_bearer(Method::POST, "/api/orders", verifier) and
the Core security baseline. The verifier requires one Authorization: Bearer
header and inserts the principal as an Axum extension. It rejects a request
that carries Cookie, Origin or Sec-Fetch-Site, so ambient browser
credentials cannot substitute for machine authentication.
Only the verified exact method/path receives the machine CSRF exception;
ordinary browser routes retain CSRF, and WAF/headers remain active. Apply bounded
bodies, ingress rate limits and no-store responses. GET/read routes can call
verify from their own authenticated handler; this constructor is deliberately
limited to the existing mutating machine-route contract.
The executable api_token_http fixture composes two independent database pools,
real HTTP servers, the actual security baseline, a required write scope and
tenant/owner authorization before a side effect. It proves body preservation,
missing/duplicate/ambient credentials, wrong scope/tenant/account, route
confusion, rotation, revocation and storage-outage rejection.
Storage and operations
SQLite uses one private connection, WAL and FULL synchronization on a trusted
local file; it is not a multi-host store. PostgreSQL uses one authoritative
writable database, verified remote TLS, four connections and five-second SQL
deadlines. Public operations have ten-second timeouts. Runtime requires USAGE
on the trusted public schema and SELECT/INSERT/UPDATE/DELETE on the recovery
and API-token tables; reserve DDL for the deployment role. The adapter checks
permanent tables and durable PostgreSQL settings on every operation.
Token operations serialize with recovery/account changes through the existing
registry write lock. This favors explicit revocation ordering over unlimited
parallel throughput; size and load-test this shared registry for the application.
Expiry and persisted clock observations are rechecked after database waits and
before a credential or principal is returned. The namespace keeps a monotonic
clock high-water mark: a host whose clock trails it by at most five seconds, as
synchronized hosts do around each whole-second boundary, adopts the recorded
time, so no lifetime is extended; a larger backward step fails with
InvalidAction. Cancellation/uncertain commit
never returns a new bearer. Revocation has a database ordering point; work
already authorized before it cannot be recalled or made atomic with a later
external side effect. Never replace SQL checks with positive local caching.
The deployment owns synchronized clocks, key storage, database encryption, replication fencing and a bounded number of configured namespaces. Rotating a configuration/key requires a deliberate migration; mismatched instances fail closed. A stale backup can revive deleted tokens and old account epochs. Quiesce authentication, invalidate restored tokens and reconcile epochs before resuming. The adapter does not implement backup anti-rollback or automatic failover.
Run cargo test -p rullst-auth --features api-tokens-sqlite for ordinary local
contracts and python3 .github/check-auth-recovery-postgres.py --suite api-tokens
for the owned disposable PostgreSQL, HTTP, restricted-role, durability and
process/server-restart contracts. The package campaign repeats the lifecycle
through extracted facade/Auth/Core archives. No real provider account is needed.
Shared passkey ceremonies
This v13 implementation, published in the 13.0.0-alpha.1 prerelease, passed real-PostgreSQL and Chromium
contracts, hosted workspace checks and isolated-archive acceptance in
PR #223. The final release campaign
remains separate; this is not a WebAuthn conformance claim. The existing synchronous
PasskeyAuth API retains its process-local, single-use challenges. The existing
SQLite credential registry persists credentials and counter CAS, not ceremonies.
The optional path belongs in rullst-auth. Enable passkey-postgres, or
auth-passkey-postgres through the umbrella package. SharedPasskeyAuth<S> composes a concrete
PasskeyCeremonyStore through static dispatch and the existing ES256/none
verification engine. The first real adapter is opt-in PostgreSQL, because it
serves separate application hosts. A shared SQLite ceremony adapter, additional
algorithms/attestation formats and discoverable-account login are separate work.
It rewrites no starter and adds no public crate.
Identity and API boundary
A server-created PasskeyBinding carries bounded opaque tenant, subject and
session references, plus a stable application-owned opaque user handle of
1–64 bytes. Constructing it validates shape; it does not authenticate a caller,
establish account ownership or authorize registration. Registration requires an
authenticated account or the application’s separately authorized enrollment flow.
For account-first login, bind the selected authoritative account and a random
pre-authentication session; the challenge alone must never choose that account.
Protect both flows with CSRF/origin and abuse controls, and refresh authorization
before persisting a credential or creating a session.
Each challenge binds a framed digest of that context, the normalized RP ID and
origin, user-verification requirement and deployment epoch. Registration options
use the supplied user handle, not an email or numeric account ID. Account-first
authentication requires 1–32 current, host-owned credentials; a digest of each
credential ID, public key and current counter is stored with the challenge.
Registration and authentication are distinct purposes. Finishing cannot change
the account, tenant, session, RP configuration, user handle or credential set.
The initial response subset does not implement discoverable account selection.
Forward the authenticator’s optional userHandle to finish_authenticate; when
present it must match the bound account handle.
Options use fresh 256-bit random challenges. Responses have explicit size limits before decoding. The private crypto validation helpers are shared with the existing API; no public stateless verification bypass is introduced. A finish checks client challenge/origin/type, atomically consumes the bound store entry, then validates the authenticator response and checks expiration again before returning. Failed verification after consumption requires a new challenge. Successful assertion verification returns the updated public credential. The host must atomically persist its counter against the previous value and check current revocation/ownership before granting a session; this increment does not invent a distributed transaction with the application’s credential repository.
Durable store boundary
PasskeyCeremonyStore is a trusted implementation contract. It exposes bounded
issue, atomic bound consume and completion-expiry validation, a stable persisted
configuration and explicit durability. SharedPasskeyAuth rejects a store that
declares process-local durability. A declaration by a custom implementation is
not an independent durability certification.
Configuration supplies a separately retained opaque deployment epoch, a maximum
of 1–100,000 pending challenges and a 1–600 second lifetime. PostgreSQL owns one
fixed private schema with metadata and pending rows. All operations validate
configuration and a persistent clock high-water mark under a transaction-level
metadata lock. Clocks are trusted server clocks; hosts must synchronize them.
Hosts cross each whole-second boundary at slightly different instants, so a
host whose clock trails the recorded high-water mark by at most five seconds
adopts that mark: shared time never moves backwards and no lifetime is
extended. A larger backward step fails closed with Configuration, not
Corrupt. Challenge and identity references are digested;
rows contain purpose, bounded credential fingerprints and issued/expiry times,
not passwords, private keys, display names, raw sessions or response bodies.
Initialization is explicit and never repairs missing runtime state. Connections use a private bounded pool, primary durable tables, synchronous commits, a fixed search path and bounded acquisition, lock and statement waits. A twelve-second whole-operation deadline also bounds startup and issue/consume/confirm when a transport stops returning replies; timeout reports an uncertain outcome. Non-loopback TCP requires TLS certificate/hostname verification. Issue serializes quota checks, bounded expired-row cleanup and insertion. Consume compares all bindings and purpose, validates the bounded stored row and deletes it in the same transaction. A mismatched context cannot remove another ceremony. There is one winner across pools and processes. No unexpired challenge is evicted to make room.
Cancellation before commit rolls back. An uncertain commit or a deadline crossed while waiting/committing returns an error; a consumed challenge may remain spent. The host retries with a new ceremony, never a local fallback. Expired rows may be removed logically; backups and physical erasure remain deployment concerns. Operators own schema permissions, synchronized clocks, TLS trust, encryption, backups and failover fencing. A stale restore with the same epoch can resurrect challenges; quiesce authentication and rotate the independently held epoch with fresh state before resuming. This adapter cannot detect physical rollback alone.
Configuration example
Initialize PostgresCeremonyStore once with a deployment role. Application
startup uses connect and cannot recreate missing state. Keep the same epoch,
capacity and lifetime on every participating instance. SharedPasskeyAuth::new
also requires the PasskeyConfig challenge lifetime and pending-challenge
maximum (defaults 300 and 10,000) to equal the store’s lifetime and capacity:
#![allow(unused)]
fn main() {
use rullst_auth::passkey::{PasskeyConfig, shared::{
CeremonyStoreConfig, PostgresCeremonyStore, SharedPasskeyAuth,
}};
async fn configure(url: String) -> Result<
SharedPasskeyAuth<PostgresCeremonyStore>, Box<dyn std::error::Error>
> {
let policy = CeremonyStoreConfig::new("independently-held-epoch", 10_000, 300)?;
let store = PostgresCeremonyStore::connect(url, policy).await?;
let rp = PasskeyConfig::new("Example", "example.com", "https://example.com");
Ok(SharedPasskeyAuth::new(&rp, store)?)
}
}
The current option DTOs retain the existing Rust JSON field conventions. Map binary challenges/user handles/credential IDs and browser dictionary names at the HTTP boundary; the tested browser fixture shows this conversion. The fixture is not a production identity service or a generated authentication controller.
Acceptance
When PR #223 admitted it, local evidence included 85 normal all-feature Auth tests, strict all-target Clippy and production zero-panic checks. The explicit PostgreSQL fixture passes independent managers/pools, actual ES256 registration/assertion, wrong bindings, credential snapshots, parallel completion, post-consumption expiry, quotas, corruption, lock-wait expiry, cancellation, stalled transport, a separate process and server restart. Chromium’s virtual authenticator completes registration and authentication through different HTTP managers and a PostgreSQL credential-counter CAS; missing cookie/CSRF and assertion replay are rejected. These checks do not demonstrate physical authenticator/device-fleet interoperability or failover safety. A focused real-database mutation sample detected all 21 selected changes to binding fingerprints, allowed credentials, issuance, consumption and expiry. This is a bounded sample, not a whole-Auth mutation score.
Changes to this path keep these acceptance and maintenance requirements:
Use real PostgreSQL with two independently constructed managers and pools, then a new process and database restart. Cover registration/assertion with actual ES256 signatures; replay, parallel completion, account/tenant/session/RP/purpose confusion, empty or changed credential sets, quotas, exact expiry, lock-wait expiry, cancellation, corrupt/oversized rows, missing metadata and configuration mismatch. Keep the existing synchronous and SQLite device-registry regressions. A minimal HTTP/browser consumer must demonstrate beginning on one manager and finishing on another without relying on shared application memory. Generated applications and an external authenticator/device fleet are separate acceptance. Run strict production lints, feature isolation and affected mutations; full workspace, archive and hosted release gates remain mandatory.
The protocol boundary follows the W3C’s registration and assertion verification procedures and their challenge/account binding requirements. This implementation still supports a deliberately bounded subset. See WebAuthn Level 3 registration and assertion verification.
Form file uploads
rullst::uploads::Multipart reads a multipart/form-data request, the body a
browser sends for a form with a file input, under explicit limits. It is new in
13.0.0-alpha.2. Text fields stay in memory; each file streams to memory while it
is small and then to a private temporary file, so a large upload never sits in
memory as a whole.
Use it for ordinary form uploads that pass through the application. For large files that the application forwards to S3 or R2 in resumable parts, see resumable private multipart uploads instead.
A handler
#![allow(unused)]
fn main() {
use rullst::uploads::{Multipart, MultipartError, MultipartPolicy, UploadKind};
async fn upload_document(multipart: Multipart) -> Result<String, MultipartError> {
let policy = MultipartPolicy::default()
.max_file_bytes(50 * 1024 * 1024)
.max_total_bytes(51 * 1024 * 1024)
.max_files(1)
.allowed_kinds([UploadKind::Pdf])
.temp_dir("storage/tmp");
let mut form = multipart.read(&policy).await?;
let title = form.field("title").unwrap_or("Untitled").to_owned();
let Some(document) = form.take_file("document") else {
return Ok("Choose a PDF to upload.".to_string());
};
// Content-addressed: the same PDF uploaded twice is the same file.
let target = format!("storage/documents/{}.pdf", document.sha256_hex());
match document.persist_to(&target).await {
Ok(()) | Err(MultipartError::PersistTargetExists) => {}
Err(error) => return Err(error),
}
Ok(format!("{title} was saved."))
}
}
Route it like any handler, for example post("/documents" => upload_document)
in routes!. Multipart must be the handler’s last argument, because it takes
the request body. MultipartError is a response: a handler can return it with
?, as above, or map it to a page of its own.
The form renders _token first (see CSRF):
<form method="post" action="/documents" enctype="multipart/form-data">
<input type="hidden" name="_token" value="…">
<input name="title">
<input type="file" name="document" accept="application/pdf">
<button>Upload</button>
</form>
Limits
Every limit has a safe default and a builder method. Exceeding one stops reading at once: the rest of the body is never read, files already received are dropped and their temporary files removed.
| Method | Default | Error | Status |
|---|---|---|---|
max_file_bytes | 10 MiB | FileTooLarge | 413 |
max_total_bytes (whole body; a larger Content-Length is refused before reading) | 16 MiB | TotalTooLarge | 413 |
max_files | 4 | TooManyFiles | 413 |
max_fields (text fields, _token included) | 32 | TooManyFields | 413 |
max_field_bytes | 64 KiB | FieldTooLarge | 413 |
max_field_name_bytes | 128 | FieldNameTooLong | 400 |
idle_timeout (wait for the next piece of the body) | 30 s | Timeout | 408 |
| part header block (fixed) | 8 KiB | PartHeadersTooLarge | 400 |
A file input left empty arrives as a part with filename="" and no content; it
is skipped and does not count against max_files. The extractor ignores axum’s
DefaultBodyLimit: max_total_bytes bounds the body instead. A body that does
not follow the form-data format (a preamble before the first boundary, a part
without Content-Disposition, a nested multipart/mixed part, a body that ends
before the closing boundary) is Malformed (400), and a text field that is not
UTF-8 is FieldNotUtf8 (400). Error responses are plain text with
Cache-Control: no-store and never echo what the client sent.
Temporary files
A file stays in memory while it is at most memory_threshold bytes (32 KiB by
default, at most 1 MiB; 0 writes every non-empty file to disk). A larger file
streams to a file named .rullst-upload-<random>.part in temp_dir, which
defaults to std::env::temp_dir() (TMPDIR on Unix). The file is created with
create_new and, on Unix, mode 0600, and it is removed when its
UploadedFile is dropped, including when reading fails.
A process that crashes, or is killed, while it holds such a file cannot remove
it. Since 13.0.0-alpha.3 the first upload that a process writes to a
temp_dir, and the first one after each further 24 hours, first sweeps that
directory: it removes the regular files named exactly
.rullst-upload-<32 lowercase hex digits>.part (the names Rullst creates)
that were last modified 24 hours ago or earlier. It never follows or removes a
symbolic link or a directory, never touches other names, and examines at most
10,000 entries and removes at most 1,000 files per sweep. The sweep runs on the
blocking thread pool before that upload’s file is created; if the directory
cannot be read or a file cannot be removed, it logs a rullst::uploads
warning and the upload continues.
UploadedFile::persist_to(target) keeps the content at target:
- it never overwrites: an existing
target, a symbolic link included, isPersistTargetExists; targetmust name a file in an existing directory and must not contain..; otherwise it isInvalidPersistTarget;- a temporary file is hard-linked into place when
temp_diris on the same file system, and copied otherwise; the new file has mode0600on Unix and is synced to disk.
Choose a temp_dir on the same file system as the final directory, and build
target from trusted values, such as an id or the SHA-256 digest, never from
the client’s file name.
bytes() returns the content of a file kept in memory, temp_path() the
temporary file of a larger one (for a scanner that reads files, for example),
and read_to_vec() either.
What the client says is not trusted
- Content type.
declared_content_type()is what the browser claimed. The checks look at the bytes:allowed_kindsadmits only theUploadKinds (PNG, JPEG, PDF, MP4, WebM, plain text) that the in-memoryUploadPolicyrecognizes, with the same rules, andexpect_signature(b"%PDF-")admits only files starting with one of the given byte strings (1 to 64 bytes each). Both run while the file streams, so a mismatch stops the upload early withContentDenied(415).kind()reports what was recognized either way. - File name.
display_name()is for display only: the last/or\segment of the client’s name, without control or invisible format characters (such as the right-to-left override that disguisesfdp.pngaspng.pdf), trimmed and cut to 255 bytes. Escape it when rendering, ashtml!does.
For a quarantine and malware-scanner flow, pass the content to
UploadPolicy::admit and UploadScanner (read_to_vec() gives the bytes).
CSRF
The CSRF middleware reads a multipart body only until its first _token field
ends, within the first 64 KiB, so that it never buffers a whole upload. Render
_token as the form’s first field, before any file input, or send the
X-CSRF-Token header (HTMX: hx-headers). Otherwise staging and production
answer 403 with:
Missing CSRF token: a multipart/form-data form must send its _token field before any file field (the CSRF check reads only the first 64 KiB of the body), or send the X-CSRF-Token header
Development and test do not mount the CSRF middleware, so Multipart::read
applies the same rule in every environment: a first _token field that ends
after the first 64 KiB, without the header, is CsrfTokenTooLate, answered with
the same 403 and text. The mistake therefore shows up before a deployment.
WAF
The WAF does not read multipart bodies. Where it runs, it marks the request,
and Multipart::read inspects the name and value of each text field with the
same signatures as a URL-encoded form body: staging and production refuse a
match with the WAF’s 403 (WafRefused), development logs it at warn level
(target rullst::security::waf, rule multipart-field-signature, the path
only). Paths listed in [security] waf_body_exempt_paths are not inspected.
File contents and file names are not inspected.
What it does not do
It does not parse documents, strip metadata, transcode media or scan for malware, and it does not resume interrupted uploads. Leftover temporary files are swept only from a directory this process writes uploads to, and only once they are 24 hours old (see Temporary files).
Private object storage
The v13 storage-s3 candidate adds optional AWS S3 and Cloudflare R2 operations
to rullst-core::Storage and the rullst facade. It supports bounded object
upload, download, metadata, deletion and signed GET URLs. Bare Core does not
enable this feature or its HTTP/signing dependencies.
Protocol tests and a digest-pinned disposable Garage service exercise signature rejection and restart persistence. Hosted workspace/platform and extracted-package source admission passed in PR #236. Final release admission remains separate. No AWS/R2 owner account has been exercised; bucket policy and provider interoperability remain deployment responsibilities.
Explicit configuration
Enable storage-s3 in Core or in the facade. To use the authorization helpers
below, also enable the facade’s security feature.
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["storage-s3", "security"] }
The version above is the 13.0.0-alpha.4 prerelease, which cargo add rullst
does not select; name it explicitly. Final release admission remains separate.
#![allow(unused)]
fn main() {
#[cfg(all(feature = "storage-s3", feature = "security"))]
fn configure() -> Result<(), Box<dyn std::error::Error>> {
use rullst::Storage;
use rullst::storage::cloud::{CloudCredentials, CloudStorageConfig};
let credentials = CloudCredentials::new(
std::env::var("R2_ACCESS_KEY_ID")?,
std::env::var("R2_SECRET_ACCESS_KEY")?,
)?;
let config = CloudStorageConfig::new(credentials).require_production()?;
let storage = Storage::r2("private-files", std::env::var("R2_ACCOUNT_ID")?)
.with_cloud_config(config)?;
let _ = storage;
Ok(())
}
}
For AWS, replace Storage::r2 with Storage::s3(bucket, region). Attaching the
configuration validates the provider parameters without making a network call.
Credentials are explicit: the library never searches environment variables,
profiles, instance metadata or ambient proxies. Known temporary credentials can
include a session token and expiration through with_session_token.
Default limits are 16 MiB per object and 30 seconds per request. A bounded
download additionally has a total operation deadline. with_limits accepts
1 byte–256 MiB and 1 millisecond–120 seconds. Requests use HTTPS, reject
redirects and disable transparent content decompression. The caller owns
concurrency limits; the per-object ceiling is not a process-wide memory quota.
Uploads are buffered and sent once. Errors do not trigger automatic retries or
imply that a timed-out write was never committed; reconcile state before retrying
an ambiguous write, especially with provider versioning enabled.
Authorize before accessing or sharing
Load account membership and file ownership from authenticated, authoritative
application state. Check the tenant boundary and then the owner/role policy
before obtaining TenantStorage or issuing a signed URL. A tenant prefix does
not authorize every member to access every object in that tenant.
TenantStorage applies tenants/<tenant>/ to upload, download, metadata,
deletion and download grants, and fails with StorageError::PathTraversal when
the tenant identifier is not exactly one normal path segment (empty, containing
/ or \, or only dots such as .). Cloud keys reject empty/parent/dot
components, backslashes, control characters and more than 1,024 UTF-8 bytes.
Request paths are the SigV4 canonical URI: the bucket and each key segment are
percent-encoded once with AWS UriEncode, so only A-Z a-z 0-9 - . _ ~ and
the / separators stay literal. Keys and tenant identifiers containing spaces,
Unicode, %, :, =, +, (, ) or other reserved characters therefore
sign and resolve as the provider expects. The archive consumer demonstrates
owner/tenant enforcement, including denial of an administrator from another
tenant, using the same facade API shipped in the archive.
Use the existing upload admission/quarantine contract before persisting or
sharing untrusted files. The storage transport is not a malware scanner. Uploads
use application/octet-stream; custom browser rendering and media transforms
are outside this increment.
Private downloads and deletion
signed_download(key, lifetime) issues a GET grant valid for 1–900 whole
seconds, bounded by any known credential expiration. It does not query object
existence. SignedDownload::expose_url() deliberately exposes the bearer secret;
its Debug output is redacted. Keep that value out of logs, analytics and public
pages. The configured private backend rejects the older unsigned url() helper.
Anyone holding a valid URL can reuse it until expiry or provider invalidation.
It is not single-use, does not recheck the application’s current permissions on
each download and cannot promise individual immediate revocation. Use an
authenticated application download endpoint when every read must consult current
access policy. expires_at() describes the requested deadline; the provider
can invalidate access earlier.
metadata returns the byte length and an optional opaque ETag. ETags are not a
portable content hash. Remote delete is idempotent for a missing key; a
versioned bucket may retain older object versions according to provider policy.
The adapter does not administer lifecycle rules, backups, bucket ACLs or IAM.
Deterministic offline mode and validation
Two empty credential values or two mock_* values select an in-memory store,
shared by clones of the configured Storage. Mixed live/mock or partially empty
credentials fail. Offline capacity is 256 objects and 64 MiB total, in addition
to the configured per-object limit. It cannot issue a real provider URL.
require_production() rejects this fallback and development endpoints.
with_loopback_test_endpoint accepts only literal-loopback HTTP(S) endpoints
without user information, path prefixes, query strings or fragments. It cannot
modify a configuration already checked for production.
The automated checks cover tenant/owner denial, canonical keys, credential and grant expiry, HTTP failures, declared/chunked response bounds and timeouts. The independent S3 fixture runs with an immutable image digest, loopback-only port, resource limits and an owned disposable data directory. It rejects unsigned, altered and expired URLs and verifies data after service restart. This verifies the implemented S3 protocol journey; it does not establish AWS/R2 account configuration, all S3 extensions or multi-region failover.
Server-mediated resumable multipart uploads are the separate opt-in
storage-multipart candidate. Direct presigned
uploads, image processing and additional S3 operations remain future increments.
Resumable private multipart uploads
The v13 prerelease (13.0.0-alpha.1) adds Core/facade storage-multipart. It extends
the existing private S3/R2 adapter with server-mediated parts and recovery after
an interrupted connection or process restart. It does not change default features.
The application sends bounded parts through its authenticated server; provider
credentials remain there. Direct browser presigned uploads are outside this API.
Bind the object and policy
Resolve the current account, tenant membership and object permission before each
operation. TenantStorage::multipart confines the object to that authenticated
tenant. Raw Storage::multipart is an operator-level API for an already-approved
complete object key. A checkpoint does not authenticate its presenter.
#![allow(unused)]
fn main() {
#[cfg(feature = "storage-multipart")]
mod example {
use rullst::{TenantStorage, storage::cloud::multipart::{
MultipartKey, MultipartLimits, MultipartStorage,
}};
use std::time::Duration;
fn uploader(tenant: &TenantStorage, independent_key: String)
-> Result<MultipartStorage, Box<dyn std::error::Error>>
{
let key = MultipartKey::new(independent_key)?;
let limits = MultipartLimits::new(
1024 * 1024 * 1024, 8 * 1024 * 1024, Duration::from_secs(86400),
)?;
Ok(tenant.multipart("quarantine/server-allocated-unique-name", key, limits)?)
}
}
}
Use the existing explicit CloudStorageConfig and its require_production
guard at startup. The checkpoint key is a separate 32-byte random secret encoded
as canonical unpadded base64url; provision it through your secret store. All
instances resuming the same uploads need the same key and exact configuration.
Rotation requires retaining the previous key/configuration for its existing
uploads until completion or abort. Debug/error output omits keys and checkpoints.
Limits fix a maximum total length, one part size from 5 to 64 MiB, at most 256
parts and an exact lifetime from one minute through seven days. Total capacity
therefore cannot exceed 16 GiB. Each begin(total_bytes) fixes the actual length;
all non-final parts have the configured size and the last has the exact remainder.
An application must separately bound concurrent requests, active uploads and
tenant quotas. Ordinary get retains its existing configured download limit;
use an authorized signed GET for objects larger than that limit.
Persist and resume
- Call
beginfor a fresh, server-allocated private/quarantine object name. - Persist the checkpoint through
expose_encoded()in an authenticated, durable application record. Restore withMultipartCheckpoint::from_encoded. - Call
upload_partwith the checkpoint, one-based number, exact bytes and expected SHA-256. Persist the returned replacement checkpoint atomically. - After restart, recreate the same uploader and call
progress. Every expected part is reported;matches_checkpointis true only when remote length and ETag match a receipt authenticated in the checkpoint. Re-upload absent or unmatched parts explicitly, including a part whose response was lost. - Call
completeonce all receipts are present. Retain the checkpoint until completion is acknowledged or explicitly reconciled.
Serialize or compare-and-swap checkpoint updates in the application. Concurrent
workers starting from the same old checkpoint can otherwise lose each other’s
receipt updates; progress detects this, but does not merge unknown receipts.
The backend retains uploaded bytes; checkpoints retain authenticated metadata,
not file content. Provider lifecycle rules must leave enough time to resume.
Checkpoints use versioned AES-256-GCM with a fresh nonce, authenticated before JSON decoding. Encoded checkpoints are capped at 256 KiB. The associated data binds endpoint, bucket, region, mock/live profile, exact object and policy, plus the ephemeral store instance in mock mode. Changing a tenant, object, endpoint, policy, key or ciphertext denies the operation. The host maintains a disciplined clock; expiry does not substitute for current membership/object authorization or a durable application revocation record.
Integrity and uncertain outcomes
Part bytes must match the supplied SHA-256 before any upload request. SigV4 signs the exact payload digest; ETags remain opaque provider receipts and are not treated as a cryptographic content digest. This is transport/content consistency, not a malware scan or proof that the client supplied safe content. Complete-file scanning and release from quarantine remain explicit application steps.
Completion sends all consecutive ETags in order and parses the bounded response. An HTTP 200 containing an XML error never counts as completion; S3 can return errors after sending its success status. See the AWS completion contract.
CompletionUncertain means the sender cannot establish the outcome. Do not
automatically initiate another upload or publish the object. Call
reconcile_completion: Confirmed requires both the expected length and the
random upload marker attached during initiation. Object existence alone is
insufficient. This observation does not prove a complete-file digest, scanning,
continued object immutability or authorization. A same-name upload can replace
an existing object as with ordinary put; allocate fresh names and control reads
until the application has accepted the content.
Abort and abandoned uploads
abort accepts authentic expired checkpoints so cleanup can continue after the
upload window closes. It sends abort and checks whether the provider still reports
the upload. RetryRequired or an error means retain the cleanup record, stop part
writers and try again. Gone refers to the multipart session; abort never deletes
an already-completed object. Concurrent writes can require repeated aborts, as
described by the AWS abort contract.
Schedule cleanup from durable application records and provision provider lifecycle expiration for abandoned multipart uploads. A lost initiation response may leave an upload whose identifier never reached the application; the checkpoint API cannot clean that up. Bucket provisioning and a global orphan sweeper are not automatic. R2 supports a subset of S3 features; consult the provider compatibility table for account/deployment requirements.
Acceptance and operational boundaries
Requests use the existing explicit HTTPS endpoint rules, no ambient proxy, no redirects and credential-expiry checks. Responses are capped at 256 KiB and 4,096 XML nodes; DTDs, excessive depth, foreign resource identities and ambiguous or truncated part lists fail closed. At most one 64 MiB part is supplied per call, with a bounded request copy; application concurrency multiplies that memory cost.
Empty or mock_* provider credentials select the bounded offline store. It has
at most 256 active upload/object entries and a shared 64 MiB content budget. It
does not survive process restart and cannot pass require_production.
Local Core regression and owned HTTP failure tests passed. The independent, digest-pinned S3 service passed both endpoint profiles, tenant isolation, authenticated part upload/list/complete/abort, new client instances, service and process restart, and completion reconciliation. Restart uses a fixed loopback port so the approved endpoint remains identical. No owner account was used. The archive-only facade consumer also passed the ordinary failure contracts and the native process/service restart journey. Seventeen archives were audited and all 135 Core source files matched the extracted bytes. Strict all-target Clippy passed; the standalone production feature graph contains no SQL backend or ORM. Combined hosted workspace/platform and extracted-package source admission subsequently passed in PR #239. Final release admission remains separate.
Shared PostgreSQL mail suppression (v13 candidate)
Mail’s optional postgres feature exposes PostgresSuppressionStore,
PostgresSuppressionConfig and SuppressionKey; the facade feature is
mail-postgres. It implements the existing SuppressionStore and
MutableSuppressionStore contracts across hosts sharing one authoritative
writable PostgreSQL database. It does not enable SQLite or change default mail
delivery. It passed combined hosted and extracted-package source admission in
PR #239; final release acceptance remains separate.
Configure and compose the store
Choose a server-owned namespace and immutable independent quotas of 1–1,000,000
recipient records and replay identities. Use a separate random 32-byte HMAC key
from the deployment secret store. SuppressionKey::new rejects trivially weak
inputs; this check cannot make a human-chosen key cryptographically random.
Namespace/key selection must come from trusted application configuration, never
from arbitrary request data. Use opaque namespaces rather than email addresses
or other personal identifiers.
Run PostgresSuppressionStore::initialize(database_url, key, config) explicitly
as a deployment task. Applications use connect with the same configuration;
missing namespaces, changed keys and quota drift fail closed. Runtime does not
create tables or reconstruct deleted state. Protect database credentials and
disable SQL statement/parameter logging outside the adapter as well.
Wrap the configured mail transport in SuppressionGuard::new(driver, store).
For multiple tenants, register one correctly scoped guard per authenticated
tenant in TenantMailResolver; avoid a fallback that silently selects another
tenant’s store. Install the composed driver through Mail::set_driver in every
web process and queue worker. Apply the guard to every physical provider attempt
when composing failover/retry transports, so a retry does not reuse an earlier
suppression decision.
The guard checks authoritative SQL state immediately before send,
send_for_tenant and send_with_delivery_id. The normal Mail pipeline still
validates messages. Mail::enqueue_for_tenant preserves the trusted tenant
context; register_mail_handler resolves the current driver when the worker
dispatches. Feedback received after enqueue can therefore block delivery.
Plain drivers are not silently wrapped; applications must install this policy.
Verified events and minimized storage
Authenticate provider webhook bodies/signatures and freshness before producing
a SuppressionEvent. The existing Resend verifier can turn verified permanent
bounces/complaints into such events. Other providers require their corresponding
verified adapter; this store does not authenticate arbitrary JSON or establish
consent. Manual suppression requires authorized application/operator policy.
Mount ingestion behind the appropriate signature verifier, bounded requests,
secure headers, WAF and ingress limits.
record atomically binds provider/event identity to its recipient, reason and
observation time. Identical replay is idempotent; conflicting reuse is rejected
without changing state. Reason precedence only increases: manual suppression,
hard bounce, then spam complaint. Earlier events can strengthen a reason but
cannot undo a complaint. Recipient normalization uses the delivery pipeline’s
recipient parser: Name <address> and <address> reduce to the bare address,
and lists, groups, comments, padding or other unparsable forms are rejected so
the guard fails closed. It then preserves the local part, which must be ASCII,
lowercases the domain and keys an internationalized domain by its IDNA A-label,
matching the existing stores; it does not infer aliases.
The database stores HMAC-derived recipient/event identifiers and fingerprints, the authoritative bounded provider/reason and first/last observation times. It does not persist raw addresses, provider event IDs, message bodies or delivery history. Callers still handle addresses in memory, and keyed identifiers remain pseudonymous data, not anonymous data. Keep keys, backups and database access protected. Debug/errors omit addresses, event identities and keys.
lookup returns the current record for the supplied normalized address.
snapshot exposes counts and quotas only. prune_events_before deletes old
replay evidence and retains every recipient suppression. Choose a positive
cutoff no later than now and older than all provider redelivery/reconciliation
windows: pruned identities may be accepted again. This API does not automatically
reenable delivery, remove complaints or implement a privacy-deletion workflow.
When capacity is exhausted, ingesting a new event fails explicitly. Investigate
and retain/reconcile verified feedback instead of acknowledging a failed write
as successfully persisted.
Database and failure boundaries
The private pool has at most four connections, verified TLS for effective remote
hosts, five-second SQL/lock deadlines and ten-second operation deadlines. Local
loopback/socket fixtures can use local transport. Every operation checks that
the three fixed public.rullst_mail_pg_suppression_* tables are permanent, the
database is writable and fsync, full_page_writes and synchronous commits are
enabled. Runtime needs:
- USAGE on the trusted
publicschema and SELECT/UPDATE on the control table. - SELECT/INSERT/UPDATE on recipient state.
- SELECT/INSERT/DELETE on replay events.
Keep CREATE/ALTER and recipient deletion with the deployment/operator role;
revoke schema CREATE from untrusted roles. Namespace row locks serialize lookups,
ingestion and retention. Quotas, clock observations and both event/recipient
writes commit together. Cancellation before commit rolls back; an uncertain
commit requires reconciliation. Storage/configuration failures make the guard
return SuppressionUnavailable without invoking its transport. That error is
classified Transient, so an outbox retries it later, but it is never
failover-eligible: FailoverDriver does not hand the unchecked recipient to
another provider.
Every operation advances a per-namespace clock high-water mark monotonically in
whole seconds. A host whose clock is at most 300 seconds behind that mark,
such as synchronized hosts crossing a second boundary at slightly different
instants, adopts the recorded time instead of failing. A larger backwards step
fails closed with InvalidConfiguration("server clock"), which the guard
reports as SuppressionUnavailable.
The host owns clock synchronization, database availability, encryption at rest, replication fencing, a bounded number of namespaces and capacity planning. A stale database restore can lose newer complaints or replay evidence; quiesce delivery and reconcile authoritative suppression before resuming. Configuration changes need a deliberate migration. The store does not implement backup anti-rollback or automatic failover.
A suppression committed after the guard’s database check cannot recall mail already handed to a provider. Distributed provider delivery is not atomic with this SQL transaction, and retries can still deliver duplicates. These controls do not prove inbox acceptance, provider account readiness or legal compliance.
Automated evidence
The candidate’s owned PostgreSQL suite exercises independent initializers/pools, replay and conflict races, reason precedence, recipient/event quotas, SQL failure rollback, cancellation, lock deadlines, clock/configuration drift, restricted runtime privileges, non-durable storage rejection and close/reopen. Fresh processes and an actual database restart verify persistent suppression and replay. The real Mail worker/tenant resolver/guard composition uses a bounded queue fixture and isolated memory transports to prove feedback after enqueue blocks the selected tenant without cross-delivery. No external inbox is contacted.
Run python3 .github/check-mail-postgres.py for the disposable loopback Docker
database. The package campaign repeats this contract through extracted
facade/Mail/Core archives; full workspace, coverage, security and release gates
remain separate.
Durable recurring publications
The v13 prerelease (13.0.0-alpha.1) adds Messaging schedules-postgres and facade
messaging-schedules-postgres. Independent application instances coordinate
through one authoritative PostgreSQL database, then relay frozen occurrences
into the existing MessageBroker contract. Core’s process-local Scheduler
keeps its existing behavior. This feature alone selects neither SQLite nor ORM.
The host owns authenticated tenant membership, schedule authoring, inspection, cancellation, operator retry and selection of the destination broker. Schedule names and namespace strings received from a request are not authorization. Apply the normal authenticated, ownership-checked, CSRF/WAF/header-protected management endpoints. Payloads must contain only necessary application data.
Configure and run
Load a 32-byte key from your secret manager and retain previous decryption keys
while any retained record uses them. initialize creates the fixed schema and
namespace once during deployment. connect requires existing state and never
repairs missing tables or changes namespace quotas. Both take an explicit clock;
use SystemClock in production. No database failure falls back to memory.
#![allow(unused)]
fn main() {
#[cfg(feature = "messaging-schedules-postgres")]
async fn schedule<B: rullst::messaging::MessageBroker>(database_url: String,
key_bytes: [u8; 32], first_after_ms: i64, payload: Vec<u8>, broker: B)
-> Result<(), Box<dyn std::error::Error>> {
use rullst::messaging::{MessagingKeyring, MessagingStorageKey, SystemClock};
use rullst::messaging::schedules::{
MissedRunPolicy, PostgresRecurringStore, RecurringConfig,
RecurringDefinition, ScheduledMessage,
};
let keys = MessagingKeyring::new(MessagingStorageKey::try_new("2026-09", key_bytes)?);
let store = PostgresRecurringStore::connect(
database_url, RecurringConfig::new("school-events", 100, 10_000)?, keys, SystemClock,
).await?;
store.create(RecurringDefinition::new(
"daily-reminder-v1", "0 9 * * *", first_after_ms,
MissedRunPolicy::Coalesce,
ScheduledMessage::new("reminders", "reminder.requested", payload)?,
)?).await?;
store.tick(20).await?;
for lease in store.claim(20).await? {
store.relay(&lease, &broker).await?;
}
Ok(())
}
}
The application supplies the worker loop and its shutdown/backoff policy. Calls
have a 10-second storage deadline; pools have at most four connections, with
5-second acquisition/SQL/lock deadlines. Broker publication is bounded by the
remaining lease and at most 10 seconds. Database and broker work do not share a
transaction. Calling close closes that pool and its clones, not other instances.
Calendar and delivery contract
Definitions are immutable. Identical creation replays the original metadata; conflicting reuse fails. Cancellation is permanent. Edit by cancelling and creating a new name. Retained definitions reserve names and count against the quota, even after cancellation; retiring a namespace is an explicit deployment operation. A random generation and scheduled UTC instant identify each occurrence.
The five fields use the Rust cron crate’s calendar semantics: minute, hour,
day-of-month, month, weekday; seconds are zero, years span 1970 through 2100.
Weekdays are 1=Sunday through 7=Saturday, or names such as Mon. Day-of-month
and weekday restrictions intersect. This is not POSIX crontab syntax: weekday
zero and seconds/year fields are rejected. There is no implicit local timezone
or daylight-saving conversion. first_after_ms is exclusive. Exhausted calendars
stop producing occurrences.
CatchUp preserves every missed due time; a tick emits the oldest due times
first, up to its 1–100 occurrence budget. Coalesce emits the oldest outstanding
due time once, then advances past observed current time. Materialization and
advancement commit atomically. Capacity failure rolls back the whole tick;
reduce the batch or purge terminal occurrences, then retry. Definitions are
limited to 10,000, occurrences to 100,000 and raw message payloads to 16 KiB.
Every materialized occurrence has a fixed delivery window starting at creation,
including caught-up work. The default is one day, with a seven-day maximum.
Leases default to 60 seconds, configurable from 1–300 seconds; the delivery
window must accommodate two configured leases. A claim fences earlier workers
with a fresh random capability and revision. A lease near the end of its
delivery window is shortened to that window; a lease that has already expired
when the claim commits is not returned (its occurrence expires or is reclaimed
normally) and no longer fails the rest of the batch. Automatic publication failures
back off exponentially and stop after ten attempts or delivery-window expiry.
retry_failed starts a new explicit operator attempt budget within the original
window and reports success once that reset is committed. It cannot revive published, cancelled, expired or purged occurrences.
A relay checks the live lease immediately before publication. Repeated attempts
use exactly the same message and purpose-separated idempotency key. Broker
acceptance followed by an uncertain acknowledgement returns
RecurringRelayError::Acknowledgement, retaining the broker receipt. A broker
error or timeout may also follow remote acceptance; recovery repeats the same
key. An invalidated lease cannot acknowledge/retry the new worker’s occurrence.
A cancellation after the final check cannot recall an in-flight publication.
Broker deduplication must remain available for at least the delivery/retry window. Early broker purging can remove that protection. Consumers must check current authorization and deduplicate external effects at the destination. A successful relay proves broker acceptance, not handler completion or exactly once external delivery. Select the same broker namespace on every retry.
Storage, roles and retention
Definitions and occurrence content use AES-256-GCM with explicit rotation keys, bound to namespace, purpose and generation/occurrence identity. Configuration has an authenticated encrypted binding; key/configuration drift fails closed. Retain old keys as long as records need them; automatic re-encryption of definitions and occurrences is not included. The namespace configuration binding is re-sealed under the current primary key by the first operation after a rotation, so the key present at initialization is not needed for it afterwards. Definitions and retained occurrence content sealed under a key still need it whenever they are read (ticks, relays and re-creating the same schedule name), and a keyring holds at most eight keys. Debug output omits content, headers and lease credentials. Metadata names are server-owned configuration and should not contain personal data.
PostgreSQL must use permanent tables, fsync, full-page writes, synchronous commits
and a writable primary. Remote connections require verified TLS. All operations
serialize through the namespace control row and recheck persisted server time
after lock waits and around commit. Instances share that recorded time but not
a clock: an instance whose clock trails it by at most five seconds adopts the
recorded time, so shared time never moves backwards and no lease or deadline is
judged earlier, while a larger regression fails with RecurringError::Clock.
The host remains responsible for trusted clock discipline (a clock running far
ahead still blocks the others until real time catches up), database credentials, backup encryption, anti-rollback policy,
failover and capacity monitoring. These checks do not certify a deployment’s
backup/failover design.
The runtime role needs USAGE on public and these table privileges:
| Table | Runtime privileges |
|---|---|
rullst_recurring_control | SELECT, UPDATE |
rullst_recurring_definitions | SELECT, INSERT, UPDATE |
rullst_recurring_occurrences | SELECT, INSERT, UPDATE, DELETE |
initialize uses a separate deployment role with schema privileges. Ordinary
workers use connect without DDL rights. Do not let other application roles
mutate this subsystem’s tables directly.
schedules and occurrences return metadata pages of 1–100 records with exclusive
cursors, without payloads or credentials. occurrences, claim and terminal
purge classify expired work and erase expired retained content. Publication and
cancellation erase occurrence ciphertext immediately; retryable dead letters
retain it until expiry or explicit purge. purge_terminal removes only terminal
occurrences older than the caller’s cutoff, up to 100 per call. Run maintenance
regularly: data is not removed while every worker is stopped.
Acceptance status
Local native contracts cover concurrent initializers/instances, immutable replay, catch-up/coalescing, real SQL rollback and lock deadlines, cancelled futures, lease expiry, cross-namespace denial, encryption binding, restricted roles, non-durable tables, quotas, retry limits, cancellation, fresh processes and actual PostgreSQL restart. An encrypted SQLite broker journey verifies acceptance before lost ACK, reopen/replay deduplication and real consumer delivery. No provider account is used. The same native journey passed through an extracted facade consumer, with Messaging source bytes matched against its archive. Combined hosted and extracted-package source admission then passed in PR #239; final release acceptance remains separate.
Durable outgoing webhooks
The v13 prerelease (13.0.0-alpha.1) adds Messaging webhooks and facade
messaging-webhooks. It provides a private encrypted SQLite outbox for one
immutable destination per server-owned namespace, signed HTTP deliveries,
bounded retry, cancellation and minimized failure inspection. It reuses the
existing broker’s idempotent publication and fenced delivery state.
This profile supports independent processes sharing a local persistent file. It does not coordinate databases on different hosts, support network filesystems or turn ephemeral container disks into durable storage. The application owns file/directory permissions, persistent volumes, backup custody and restart policy. The feature does not enable itself by default.
Publish and supervise delivery
Authorize the tenant/account and operation before selecting a namespace, creating a destination, publishing, cancelling or inspecting events. Never accept an arbitrary request URL as an approved destination. An authenticated management endpoint must retain ownership checks and the normal CSRF/WAF/secure-header baseline. The library does not add HTTP management routes or grant authority.
#![allow(unused)]
fn main() {
#[cfg(feature = "messaging-webhooks")]
async fn deliver(database_url: String, encoded_signing_key: String,
storage_key_bytes: [u8; 32], json_bytes: Vec<u8>)
-> Result<(), Box<dyn std::error::Error>> {
use rullst::messaging::{MessagingKeyring, MessagingStorageKey, SystemClock};
use rullst::messaging::webhooks::{
WebhookConfig, WebhookDestination, WebhookOutbox, WebhookSigningKey,
};
let config = WebhookConfig::new(
"school-webhooks",
WebhookDestination::approved_https("https://receiver.example/events")?,
10_000,
)?.require_production()?;
let signing = WebhookSigningKey::new("receiver-key-2026-09", encoded_signing_key)?;
let storage = MessagingKeyring::new(
MessagingStorageKey::try_new("storage-key-2026-09", storage_key_bytes)?,
);
let outbox = WebhookOutbox::open(database_url, config, signing, storage, SystemClock).await?;
outbox.enqueue("completion/42/v1", "course.completed", json_bytes).await?;
let outcome = outbox.dispatch_next("worker-1").await?;
Ok(())
}
}
encoded_signing_key is canonical unpadded base64url for exactly 32 CSPRNG bytes.
Use a separate secret for encrypted storage. Empty or mock_* signing credentials
select deterministic offline delivery with an explicit offline: true result;
production configuration rejects them. Offline signing keys never authenticate
incoming HTTP signatures. Test destinations accept only literal loopback HTTP(S)
and optionally an owned test CA; production configuration rejects that mode.
open creates or reopens the encrypted outbox, reserves one internal control
record and subscribes its private delivery group. The encrypted control record
binds destination, key identity/fingerprint, mode, retention quota and delivery
window. Configuration/key drift fails instead of sending old payloads to a new
receiver: reopening with a changed destination, signing key, window, mode or
retention, or with a storage keyring that cannot open the namespace, returns
WebhookError::Configuration. Changing destination or signing key requires an explicitly new namespace
and an application-owned drain/cutover policy; receiver key overlap is managed by
the host. Storage keyrings can retain old decryption keys while new writes use a
new primary. Do not remove keys while retained records depend on them. Opening
with a new primary re-seals the private control record under it, so once events
sealed under an old key are acknowledged or dead-lettered and purged, that key
can be removed.
Enqueue preserves exact JSON bytes up to 64 KiB and a bounded event kind. Reusing a retained application event key with different bytes or kind fails; identical retries return the original message ID. Persisted application event keys are HMAC-derived; the raw event key is not retained. The application database transaction and this outbox are separate commits. Bridge from an application transactional outbox when atomic domain-change publication is required.
Run dispatch_next from a supervised loop with bounded concurrency, backoff and
shutdown. It claims one event at a time with a 30-second lease, resolves the
endpoint, rechecks the authoritative SQL lease, sends and then acknowledges.
Storage calls have a 10-second deadline. DNS/connect operations are bounded by
3 seconds, and HTTP by the remaining lease/window and at most 10 seconds.
close closes that outbox pool and its clones, not independently opened workers.
Destination and receiver security
Production uses HTTPS with certificate validation, disables proxies and redirects, and resolves the approved hostname freshly per attempt. All returned addresses must pass the public-address policy, with a 16-address ceiling. The approved addresses are pinned to that attempt’s connection; a subsequent DNS answer cannot choose another destination. Private, loopback, link-local, multicast, reserved, documentation and translation/transition ranges are denied. IPv6 admission is conservative ordinary global unicast. Deployments still own resolver trust, network routing and egress policy; this does not replace a firewall.
These controls were checked against the IANA IPv4 and IPv6 registries and OWASP SSRF guidance. Review address policy when registries change. Deliberate conservative denial can exclude specialized globally reachable protocol addresses.
Every POST sends Content-Type: application/json and five signature headers:
| Header | Value |
|---|---|
Rullst-Webhook-Id | Stable broker message ID, preserved on retry |
Rullst-Webhook-Type | Frozen event kind |
Rullst-Webhook-Timestamp | Canonical integer Unix seconds for this attempt |
Rullst-Webhook-Key-Id | Server-selected signing-key identifier |
Rullst-Webhook-Signature | v1= plus canonical unpadded base64url HMAC-SHA256 |
The MAC input concatenates six byte strings, each preceded by its 8-byte
unsigned big-endian byte length, in this order: rullst.webhook.signature.v1,
key ID, delivery ID, event kind, decimal timestamp and the exact body bytes.
Do not parse/re-serialize JSON before verification. The signature header is
marked sensitive in the HTTP client’s header map. Error/Debug surfaces omit keys,
payloads, destination URLs and response bodies.
Receivers should call WebhookSignature::from_http_headers to reject missing,
malformed or duplicate signature headers, then WebhookSigningKey::verify on a
bounded raw body and trusted clock. The maximum accepted skew is explicitly
1–300 whole seconds. Axum/http 1 header maps are compatible. The lower-level
from_headers constructor assumes the caller already rejected duplicates.
Verification uses constant-time HMAC validation and binds every signed field. It does not store replay state. Authenticate before creating the receiver’s transactional deduplication record, deduplicate by delivery ID, authorize the event’s application context and record the side effect atomically when possible. Signature verification alone neither grants tenant authority nor prevents replay.
Retries, cancellation and retention
Any 2xx status means receiver acceptance, not completion of its external effects.
408, 425, 429 and 5xx responses, DNS failures and transport errors use bounded
exponential backoff. A bounded integer Retry-After up to one hour can lengthen
the delay. Redirects, other permanent responses and forbidden destinations become
terminal. Arbitrary receiver bodies are never buffered or persisted.
Automatic delivery stops after ten attempts or its immutable delivery window.
The window defaults to one day and is configurable from one minute through seven
days. A timeout can happen after the receiver accepted a request; retries preserve
the exact body and ID. If a successful HTTP response precedes a failed/uncertain
local ACK, WebhookError::Acknowledgement retains the stable ID and status.
Receiver-side effect deduplication remains necessary.
cancel permanently invalidates pending/leased/failed work. It cannot recall a
request already in flight or undo a receiver effect. Stale workers cannot
acknowledge or retry the cancelled claim. retry_failed starts a new explicit
operator attempt budget with the same ID and original deadline; it cannot revive
cancelled, accepted, purged or expired work. Host applications authorize these
management actions and own disciplined trusted time. The shared broker does not
provide persistent clock anti-rollback or certify restore/failover behavior.
failed returns up to 100 minimized terminal records: ID, kind, attempts, bounded
failure code and time. No payload or destination is returned. At most 99,999
application events can be retained, in addition to the control record. Accepted
and failed events retain encrypted content until explicit maintenance.
purge_terminal deletes at most 100 terminal events older than the creation
cutoff; the cutoff must precede now by the full delivery window. Active deliveries
and control state are preserved. Purging also removes sender idempotency evidence;
choose receiver retention and business idempotency accordingly.
Local acceptance status
Owned HTTP/TLS tests pass exact-body signatures, receiver deduplication after a lost response, certificate trust, redirect/private-address denial, concurrent workers, cancellation during delivery, bounded retries, manual retry, expiry, quota/configuration/namespace isolation, actual SQL outage/cancelled futures and terminal retention. A fresh child process delivers a persisted event, and a second process observes its durable acknowledgement. Broker lock-contention tests also reproduce and correct the earlier pre-lock clock sampling defect.
No provider account or external recipient is used. Seventeen extracted archives were audited; the facade consumer passed the same HTTP/TLS and fresh-process journeys with 53 Messaging source files byte-matched to the candidate. All-feature Messaging regression (63 ordinary tests), strict all-target Clippy and the native recurring PostgreSQL regression passed. Combined hosted and extracted-package source admission then passed in PR #239; final release acceptance remains separate.
Redis Streams messaging
The v13 candidate adds RedisBroker to the existing rullst-messaging crate.
Enable redis-streams directly, or messaging-redis on the rullst facade.
The default messaging build remains network-free; the facade feature does not
enable ORM, mail, cache or Core queues. Hosted workspace/platform and extracted-package source admission passed in PR #236. Final release admission remains separate.
Acceptance uses an owned, digest-pinned Redis 7.4 service, not an
owner’s account or production data.
Configuration and startup
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["messaging-redis"] }
This version is the 13.0.0-alpha.4 prerelease, which cargo add rullst does not
select; name it explicitly. Final release admission remains separate.
#![allow(unused)]
fn main() {
#[cfg(feature = "messaging-redis")]
async fn startup() -> Result<(), Box<dyn std::error::Error>> {
use rullst::messaging::{BrokerConfig, RedisBroker, RedisBrokerConfig};
let configuration = RedisBrokerConfig::try_new(
BrokerConfig::try_new("academy-notifications")?,
"deployment-2026-09", // Stable deployment generation, shared by all workers.
std::env::var("MESSAGING_REDIS_ENDPOINT")?, // rediss://host:port/0
std::env::var("MESSAGING_REDIS_USERNAME")?,
std::env::var("MESSAGING_REDIS_PASSWORD")?,
)?.require_production()?;
// An operator previously called RedisBroker::provision with this configuration.
let broker = RedisBroker::connect(configuration).await?;
let _ = broker;
Ok(())
}
}
Call provision once for an intentionally empty namespace. On ordinary process
startup call connect, which checks stored generation and limits and rejects
missing state. Never respond to a startup error by automatically provisioning a
replacement. Keep the deployment generation stable across process restarts;
restoring a backup requires explicit reconciliation, not a new random generation
on every startup. Changing generation or limits for retained state fails.
The stored state format is part of that check: a namespace provisioned by an
earlier v13 development build (format v1) fails with ConfigurationConflict.
Drain it with that build, then provision a new namespace or generation.
Credentials are explicit and separate from the URL. Only database zero is
supported. URL credentials, fragments, query options and insecure TLS overrides
are rejected. TLS validates the certificate and hostname. with_ca_certificate
accepts an explicit PEM trust bundle up to 64 KiB; it preserves hostname checks.
The normal trust roots come from the maintained Redis client’s WebPKI profile.
For TLS connections, the adapter preserves an installed Rustls process provider;
if none exists, it installs aws-lc-rs, the framework’s rustls provider (see
Cryptography providers),
before Redis builds its TLS client. Applications requiring another provider must
install it before opening the broker.
This avoids implicit-provider panics when dependencies enable multiple providers;
certificate, hostname and trust-root validation remain required.
The host owns credential rotation and secret storage. Diagnostics redact endpoint,
password, deployment generation, payload, header values and ACK capabilities.
An empty or mock_* password selects the explicit process-local InMemoryBroker
fixture without a network connection. Its clones share state; separate connect
calls do not create a distributed fixture. require_production rejects mock
credentials and the allow_loopback_for_tests transport override. That override
permits plaintext only for a literal loopback IP and cannot be enabled after
sealing a configuration for production. Live connection or protocol errors
never activate the fixture. The mock implements the common broker contract;
Redis-specific byte budgets and remote failures require the actual service tests.
Supported contract
Redis Streams retains canonical rullst.messaging.v1 envelopes. Rullst owns the
group, ready-time, attempt, terminal and lease indexes in the same namespace.
These groups implement MessageBroker; they are not native XREADGROUP
consumer groups. Other clients must not mutate these keys or mix native ACK,
trimming or group operations into this layout.
| Operation | Supported behavior |
|---|---|
publish | Exact topic/key replay returns the original ID and publication time; changed payload, headers or content metadata conflict. A server response acknowledges the append and index mutations. |
subscribe | Idempotent group registration with earliest-retained or future-only start. Separate groups receive independent copies. |
receive | Non-blocking bounded claims shared between consumers; authoritative Redis time determines lease expiry. A fresh random capability replaces an expired lease. |
ack | The current unexpired capability is consumed once. An expired or replaced capability cannot settle a newer delivery. |
retry | Delays up to seven days; the next receive claims due messages. Reaching the configured attempt ceiling sends the delivery to the group’s DLQ. |
dead_letter / dead_letters | Explicit current-lease terminal failure and bounded inspection without ACK secrets. Lease-expiry attempt exhaustion is processed on subsequent operations. |
purge_terminal | Removes at most 100 messages per call, even if a larger limit is requested, and only after every current group is terminal or started after the message. With no groups it removes nothing. It also removes publication deduplication records. |
Server-side scripts serialize mutations, so independently connected consumers cannot claim the same live lease. Delivery remains at least once: an external effect can succeed before its ACK fails. Deduplicate effects using the stable message ID or a domain key. Retain deduplication state long enough for the application’s retry/recovery horizon; republishing after terminal purge creates a new message. Messages that become available at the same time, for example every retained message of a new earliest group, are claimed in publication order. Delayed retries and competing consumers do not promise global or strict FIFO processing order.
Limits and failure handling
The profile accepts at most 10,000 retained messages, 128 subscriptions,
100 attempts and 1 MiB per payload, with the lower BrokerConfig limits
also enforced. Canonical retained envelope bytes have a separate 64 MiB
ceiling. Header/routing overhead counts toward that ceiling. Delivery and DLQ
responses stop at 4 MiB of encoded envelopes, so a batch can contain fewer
than the requested maximum. These bounds are not a total Redis or process memory
quota: indexes, protocol buffers, concurrent calls and separate namespaces also
consume memory. The server is a trusted authenticated dependency, not an
untrusted RESP parser sandbox.
At most eight operations per cloned broker family are admitted concurrently;
excess calls fail immediately. The default total operation deadline is ten
seconds; with_timeout permits 100 milliseconds–30 seconds. There is no
automatic write retry. A timeout or lost response does not establish whether a
write committed. Replay a publication with the same key/content; let uncertain
delivery claims expire and preserve destination idempotency. A later operation
may establish a fresh connection to the same endpoint and must recheck persisted
namespace state.
Redis scripts provide isolation, not rollback after an execution error. Every mutation writes a persistent in-progress marker first and clears it only after all commands succeed. A partial failure leaves the namespace quarantined; subsequent operations and startup reject it. There is deliberately no public “clear dirty” repair helper. Recover to a reviewed consistent snapshot or rebuild in a separately provisioned namespace from authoritative source events, checking already-completed effects. Keep the original evidence for diagnosis. The marker cannot detect arbitrary administrator edits or a coherent old backup rollback.
Publication fans out over registered groups. Creating an earliest subscription indexes retained messages; purge examines current groups. Those operations have bounded work but can briefly block the dedicated Redis server. Subscription creation belongs in deployment/configuration work, not per-request handlers.
Deployment responsibilities
Use a dedicated standalone Redis 7.4+ database, protected network and narrowly
scoped ACL identity. The key prefix is
rullst:messaging:v1:<SHA-256(namespace)>:; namespace hashing avoids delimiter
collisions but is not authorization or encryption. The SDK needs EVAL, TIME
and the hash, set, sorted-set and stream commands used by its fixed scripts.
It does not need configuration, ACL-management, FLUSHDB or arbitrary key
administration permissions. The application must authorize namespace/topic/group
access before invoking broker operations; never hand the raw Redis credentials
to an untrusted client. Lua source is included in the crate archive for audit.
Configure AOF persistence, an appropriate fsync policy and no eviction.
The acceptance fixture uses appendfsync always and abrupt process restart.
The SDK does not change or certify server configuration. Persistence guarantees
still depend on storage and server operation; acknowledgements are not universal
zero-data-loss guarantees. Monitor disk/memory, server time and failed operations.
Clock regression relative to persisted operation time rejects mutations until
the server’s time catches up or a reviewed recovery occurs. Redis contents,
headers, deduplication metadata and leases are plaintext at rest; protect AOF,
volumes and backups separately. The encrypted SQLite profile is not automatically
applied to this remote adapter.
Replication/failover, Sentinel, Cluster, managed-provider certification, native consumer-group interoperability, automatic trimming/repair, unbounded replay, multi-region ordering and exactly-once effects are outside this profile.
Outbox and executable evidence
Add messaging-orm-outbox, with the application’s database feature
(strict-postgres, strict-mysql, strict-sqlite or drivers-all), to relay
committed relational events through the existing OrmOutboxRelay<RedisBroker>.
Commit the domain write and event in the same ORM transaction. A worker publishes the stream-scoped durable event key, then ACKs its
exact outbox claim. Crashing between these operations produces an exact broker
replay when reclaimed. This still does not create a distributed transaction.
The disposable service runner executes the common broker contract, competing clients, exact/conflicting replay, expired/replaced leases, retry/DLQ, retention/reply limits, partial-write quarantine, missing state, timeout reconciliation, trusted/untrusted TLS, hostname checks, an actual ORM outbox and an AOF server restart. Test fixtures use only temporary data, local certificates and public test credentials. The facade consumer is also compiled from extracted package sources. Hosted full-workspace and package checks passed in PR #236; final release checks remain a separate admission requirement.
Recoverable server-driven Live UI
The v13 prerelease (13.0.0-alpha.1) adds rullst::live::recovery in the existing Core
and facade, with no extra feature or browser dependency. It is an explicitly
mounted native-server API; the browser uses the supplied small ES module.
Protocol and Chromium acceptance have passed. Hosted workspace/platform and extracted-package source admission passed in PR #236. Final release admission remains separate.
The older LiveComponent, Live::mount and make:live example keep their
per-connection state and HTMX WebSocket protocol. Their live_ws_handler now
limits incoming frames and messages to 64 KiB (previously the 64 MiB transport
default) and ignores Ping/Pong keepalive frames instead of ending the session,
but still has no origin check, connection cap or idle timeout. Adopting the recovery API is
an application change; upgrading a dependency does not migrate those components.
Application contract
Implement RecoverableLiveView with three asynchronous methods:
| Method | Required application behavior |
|---|---|
authorize | Recheck the captured authenticated session, expiry, current tenant membership and permission for this exact component/resource. Return Unauthorized on revocation; storage failure is an error. |
snapshot | Read authoritative state and render a complete LiveSnapshot with its persistent monotonic u64 revision. Escape untrusted values before including them in HTML. |
apply | Validate the requested action, fields and current write permission. Atomically compare command.expected_revision() with the domain revision in the same transaction as the mutation, then advance it. On mismatch return Conflict without writing. |
LiveScope binds an authenticated tenant, account and component key, not a
browser-supplied role or identity. Construct it from verified server state and
TenantMembership::select. Scope construction alone does not authorize access.
The implementation uses static dispatch and mounts no routes automatically.
For SQL, a bound update such as UPDATE ... SET revision = revision + 1 WHERE tenant = ? AND id = ? AND revision = ? belongs inside the domain transaction,
alongside any required current authorization check. Check the affected row
count before reporting success. A separate read followed by an unconditional
write cannot protect two simultaneous sockets. Never interpolate form fields
into SQL identifiers or treat a field named tenant as the trusted scope.
Route and browser composition
This adapter receives a scope and view already resolved by the host’s authenticated route. It is not an authentication extractor:
#![allow(unused)]
fn main() {
use rullst::live::recovery::{
LiveRecovery, LiveRecoveryConfig, LiveScope, RecoverableLiveView,
};
use rullst::web::axum::{
extract::WebSocketUpgrade, http::HeaderMap, response::Response,
};
fn configured_live() -> Result<LiveRecovery, rullst::live::recovery::LiveRecoveryError> {
Ok(LiveRecovery::new(
LiveRecoveryConfig::try_new("https://academy.example")?,
))
}
async fn authorized_upgrade<C: RecoverableLiveView>(
shared: &LiveRecovery,
websocket: WebSocketUpgrade,
headers: &HeaderMap,
trusted_scope: LiveScope,
authenticated_view: C,
) -> Response {
shared.upgrade(websocket, headers, trusted_scope, authenticated_view).await
}
}
Create the handler once in application state; its clones share admission limits.
Use Server or apply_security_baseline for the HTTP routes and enforce ordinary
application authentication/ownership. Serve LIVE_RECOVERY_MODULE at an explicit
same-origin JavaScript route with a JavaScript content type. Load a separate
application module under the site’s CSP; no inline-script exemption is needed.
The browser application module can contain:
import { connectLive } from '/assets/rullst-live.js';
const live = connectLive(
document.getElementById('lesson-view'),
'/live/lesson',
document.getElementById('connection-status'),
);
// A false return means the action was not sent. There is no offline queue.
// live.send('save-answer', { answer: '42' });
The root and optional status element must already exist. Render
<button type="button" data-live-action="increment"> for an action without
fields, or a form with data-live-action="save-answer" and named string inputs.
Duplicate field names and file inputs are rejected. Submit buttons delegate to
the form; the named fields, rather than the submit button value, form the command.
Only one action can be pending. Controls with data-live-action are disabled
while unavailable or awaiting a response; programmatic send enforces the same
admission even when a form’s submit button has no such attribute.
Every accepted snapshot replaces the root’s HTML. This is not DOM diffing: unsent edits, focus, selection and root-child event listeners may be replaced. Keep unsaved drafts outside this root if the application needs to preserve them, and use delegated listeners. Server-rendered HTML is trusted content; the transport is not a sanitizer. Never include secrets, executable user HTML or unescaped form values in a snapshot.
Recovery and outcomes
On connection or reconnection, the server sends current full state. Commands use
rullst.live.v1, a random 32-hex correlation ID and a decimal-string revision to
avoid JavaScript integer rounding. A stale command yields a fresh snapshot with
conflict. A successfully committed action yields applied and a newer revision.
The browser emits rullst:live-state with { state, pending } and
rullst:live-result with { id, outcome }. Outcomes are recovered, applied,
conflict or unknown. If the connection fails while an action is pending,
unknown means it may already have committed. The client never automatically
replays it. Recover authoritative state before allowing a person to retry an
external effect; use separate durable domain idempotency for payments or email.
The correlation ID alone does not supply deduplication.
Reconnect attempts use capped exponential backoff with jitter (250 milliseconds
to a base cap of ten seconds, at most twelve consecutive failed attempts).
A received valid snapshot resets the retry budget. Handshake/action waits have
a thirty-second browser deadline. reconnect() explicitly retries with a fresh
budget; dispose() removes listeners and closes the socket. Recovery converges
to state, not replay of every missed transient event or automatic broadcast of
changes made by other clients. A still-connected client learns other changes on
its next action/conflict or explicit reconnect.
Origin must match the configured HTTPS origin exactly; omitted, multiple, null
and foreign origins fail. The only offered subprotocol must be rullst.live.v1.
For local fixtures, loopback_for_tests permits HTTP at a literal loopback IP.
The production route must not infer this configuration from an untrusted Host
or forwarded header. Use secure application cookies and trusted TLS/proxy setup.
Authorization runs before upgrade, before domain actions, after domain I/O and
before sending each snapshot, and periodically while connected. An observed
revocation closes with 4401; the client clears its root and reports denied.
Changing or clearing a browser cookie alone cannot revoke an existing socket:
revoke its captured server session. Already-committed actions are not rolled
back by later revocation. A race after the final authorization check cannot
recall bytes already sent; the host owns transaction-level permission checks.
HTTP upgrade failures are intentionally opaque to browser WebSocket clients;
they use bounded reconnect and eventually unavailable. Ordinary disconnects
can retain the previously displayed HTML. Do not interpret that cached display
as a current authorization decision. An explicit forbidden/invalid close clears
it; applications can also clear their view on logout or any disconnected state.
Bounds and deployment
| Resource | Default and hard boundary |
|---|---|
| Shared connections | 128; configurable 1–1,024 per cloned handler family |
| Actions per connection | 128; configurable 1–1,024, then reconnect required |
| Callback or send | Five seconds; configurable 100 milliseconds–ten seconds |
| Session revalidation | Every five seconds; configurable 100 milliseconds–thirty seconds |
| Connection lifetime | One hour; configurable one second–one hour |
| Command/frame | 16 KiB; 32 unique fields, each name/action at most 64 ASCII bytes, each value at most 2 KiB |
| Snapshot | 64 KiB UTF-8 HTML; encoded response at most 512 KiB |
| Socket buffers | 16 KiB read/write buffer targets; 1 MiB maximum write buffer |
The server pings periodically and closes a peer that stays silent for more than three revalidation intervals of waiting. Time the server spends in its own callbacks and sends, when it does not read the socket, is not counted as the peer’s silence. Domain callbacks must perform asynchronous, cancellation-safe work; a Tokio timeout cannot preempt blocking CPU work or prove that a canceled database operation rolled back. Limits bound this handler, not all process memory or aggregate users across replicas. Use ingress/per-account rate limits for repeated reconnects and infrastructure-level abuse controls.
Oversized frames terminate the connection before domain actions. Rejecting the payload at the transport limit can reset TCP before a close frame reaches the peer; clients must not depend on receiving an application close code in that case.
Application persistence and shared revision/permission authority are required for restart or cross-replica recovery. Neither this handler nor the browser persists domain state. Keep revision epochs consistent when restoring backups. Configure the proxy for WebSocket upgrades and suitable idle timeouts; TLS, proxy behavior, replication and failover need deployment-specific validation. This candidate does not add WebSocket ticket issuance, frame encryption, DOM patching, an offline mutation queue or automatic starter migration.
Executable acceptance
The protocol suite uses actual WebSockets, a parameterized SQLite domain transaction and the production HTTP baseline. It tests concurrent revision conflicts, uncertain committed actions, origin/session/tenant denial, revocation before disclosure, binary/oversized input, admission, idle peers, action limits, lifetimes and callback deadlines.
The Chromium journey exercises two pages, offline/online transitions, exactly one send of an uncertain action, read-only learner permissions, CSRF-protected logout and recovery after killing and restarting the actual application process against the same database. It is explicitly executed in the existing Linux workspace and coverage jobs. These disposable fixtures use no real account credentials. They establish local behavior, not release admission or a guarantee for every proxy/browser/deployment.
Tracing operations across processes
The v13 prerelease (13.0.0-alpha.1) extends Core/facade’s optional telemetry feature
with telemetry::distributed. It propagates an explicitly trusted parent and
exports a minimized operation trace through the maintained OpenTelemetry SDK.
The existing SpanCollector stays process-local; Studio’s authenticated trace
ingestion remains its separate protocol and is not an OTLP collector.
Acceptance includes independent Messaging producer/consumer processes and the standard OpenTelemetry Collector 0.161.0 over verified TLS. Hosted workspace/platform and extracted-package source admission passed in PR #236. Final release admission remains separate.
Explicit configuration and lifecycle
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["telemetry"] }
tracing = "0.1.44"
tracing-subscriber = "0.3"
This version is the 13.0.0-alpha.4 prerelease, which cargo add rullst does not
select; name it explicitly. Final release admission remains separate.
#![allow(unused)]
fn main() {
#[cfg(feature = "telemetry")]
fn configure_traces() -> Result<rullst::telemetry::distributed::DistributedTelemetry, Box<dyn std::error::Error>> {
use rullst::telemetry::distributed::{DistributedTelemetry, TelemetryConfig};
use tracing_subscriber::prelude::*;
let config = TelemetryConfig::try_new(
"academy-web",
std::env::var("ACADEMY_OTLP_TRACES_URL")?, // https://collector.example/v1/traces
["academy.request", "academy.publish", "academy.handle", "rullst.orm.query"],
)?.with_sampling(0.1)?.require_production()?;
let runtime = DistributedTelemetry::try_new(config)?;
tracing::subscriber::set_global_default(
tracing_subscriber::registry().with(runtime.layer()),
)?;
Ok(runtime)
}
}
Retain the returned runtime for the application lifetime. Install its layer once,
before Server initializes its legacy subscriber. It composes with an ordinary
tracing_subscriber registry and does not replace a global subscriber or provider
automatically. Other formatting/export layers have independent data policies.
Use stable, non-personal service and operation labels: 1–64 ASCII letters, digits, dots, underscores or hyphens, with 1–64 distinct approved operations. Operation names must match the actual span names. Do not put an account, email, tenant, URL, SQL statement or request identifier into those configuration labels. They are approved by the application, not automatically classified as harmless.
The endpoint is explicit, with the exact /v1/traces path and no URL credentials,
query or fragment. with_bearer_token accepts an explicit token up to 8 KiB;
with_ca_certificate accepts up to 64 KiB of PEM CA material and preserves
certificate/hostname checks. require_production rejects offline or HTTP
profiles. loopback_for_tests explicitly permits HTTP only at a literal loopback
IP, including a separately protected local collector.
Empty or mock_* endpoints select an explicit offline fixture: spans are counted
and discarded without network traffic. is_mock() exposes that choice. A failed
live collector never activates it. Offline counters do not prove interoperability
or delivery, and the fixture retains no span payloads.
Parent trust and async execution
An incoming trace ID is correlation metadata, not proof of identity or tenant
membership. Apply authentication and domain authorization separately. Default
untrusted public ingress to ParentPolicy::StartNew; select TrustedPeer only
after the host has established the upstream identity and correlation boundary.
This also prevents an untrusted sampled flag from overriding root sampling.
#![allow(unused)]
fn main() {
#[cfg(feature = "telemetry")]
async fn observed_request(headers: &rullst::http::HeaderMap) -> Result<(), rullst::telemetry::distributed::TelemetryError> {
use rullst::telemetry::distributed::{ParentPolicy, TraceParent};
use tracing::Instrument;
let span = tracing::info_span!(parent: None, "academy.request", otel.kind = "server");
TraceParent::apply_incoming(&span, headers, ParentPolicy::StartNew)?;
async {
// The application's separately authorized asynchronous work goes here.
}.instrument(span).await;
Ok(())
}
}
Use Instrument to enter spans while their futures are polled; do not retain
an entered-span or thread-local context guard across .await. Existing ORM
spans participate when their operation names are approved and the domain future
runs inside the parent span.
TraceParent accepts an exact 55-byte, lowercase, nonzero W3C version-00
traceparent. Duplicate HTTP values and unsupported/malformed trusted context
return InvalidContext; the host can explicitly choose to start a new root for
invalid tracing metadata without changing its authorization decision.
The minimized profile deliberately omits vendor tracestate and arbitrary
baggage; it is a documented subset, not full W3C forwarding conformance.
capture obtains a current span’s outgoing parent; inject replaces traceparent
and removes those two metadata headers, leaving unrelated application headers
alone. Authorize the destination before sending any headers.
For Messaging, place TraceContext::try_new(parent.as_str()) into
PublishRequest::with_trace_context. The consumer reads the retained envelope’s
trace_context, validates it with TraceParent::try_new, and calls attach_to
on its new consumer span only after trusting that producer/topic. A worker’s new
span ID remains distinct from the producer’s. The
independent-process fixture executes
this journey through the shared-local SQLite broker and checks a nested ACK
operation. Redis and other validated carriers can carry the same values, but
this particular acceptance does not claim every transport or deployment.
Metadata and bounded export
The profile exports approved operation/service names, trace/span/parent IDs, kind, timing and status code. Before queueing, it removes application attributes, events, links, vendor state and error descriptions. It also avoids ambient resource detectors, replacing instrumentation-scope metadata with a fixed label. The SDK span limits disable attribute/event/link retention for this provider. This boundary does not sanitize other log layers, user-supplied configuration labels, instrumentation allocations before SDK processing, or application data.
| Export resource | Default and supported limit |
|---|---|
| SDK queued spans | 256; configurable 1–4,096 |
| Batch size | 32; configurable 1–128 and no larger than the queue |
| Scheduled export interval | One second; configurable 100 milliseconds–thirty seconds |
| Total network operation | Three seconds; configurable 100 milliseconds–ten seconds |
| Encoded request | At most 1 MiB |
| Collector response | At most 16 KiB, including responses without Content-Length |
| Explicit lifecycle operation | SDK flush/shutdown deadlines; shutdown uses five seconds |
with_limits sets queue, batch, interval and network deadline. Export uses the
SDK’s dedicated thread and a compatible blocking HTTP client created on that
thread. Request-side span completion never waits for collector I/O; a saturated
queue drops telemetry. These are export-queue bounds, not a quota for every
active application span or all process memory.
The transport disables redirects and ambient proxies, verifies TLS and accepts only bounded OTLP/HTTP protobuf responses. Malformed data, partial acceptance, HTTP errors and timeouts count as failed batches. It never logs arbitrary collector error text. There is no application-side durable spool or automatic retry of an uncertain batch. Explicit configuration controls endpoint, bearer token, sampling, resources and queue bounds; ambient SDK header values are not forwarded by this profile. Incompatible SDK compression settings fail closed.
status() exposes metadata-only counters. forwarded_spans means offered to the
SDK, so it includes spans later dropped during saturation. accepted_batches
means a valid fully accepted OTLP response, not proof of durable backend storage.
failed_batches records transport/protocol failures; offline_spans records the
explicit fixture; rejected_spans records disallowed names/invalid span metadata.
These counters do not form an exact per-span loss accounting system.
After ending the application’s active spans, call flush().await and eventually
shutdown().await from Tokio. These move blocking SDK lifecycle work off the
request executor. Only one explicit flush is admitted at a time. A successful
flush does not recover previously dropped or failed batches; a shutdown timeout
does not prove all traces were delivered. A process kill can lose queued spans.
Operate collector authentication, network policy, sampling budgets, retention,
backups, availability and data access separately. Traces are not an audit ledger.
Legacy initialization and executable evidence
init_telemetry remains the legacy general tracing subscriber. The v13 candidate
repairs its unsupported async-client/thread-processor combination with the same
compatible bounded HTTP transport. Its default is now OTLP/HTTP at
http://127.0.0.1:4318/v1/traces, rather than the gRPC port. An explicit
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is used exactly; the generic
OTEL_EXPORTER_OTLP_ENDPOINT is a base with /v1/traces appended. Review existing
environment configuration during migration. Legacy formatting, general span
fields, ambient resource detection and explicit SDK headers keep their separate
policy; they do not acquire the minimized profile automatically. Install the
owned pipeline above when its privacy and lifecycle controls are required.
Because legacy spans keep their attributes and events, the legacy exporter
sends at most 64 spans per request unless OTEL_BSP_MAX_EXPORT_BATCH_SIZE sets
another size, so batches stay under the shared 1 MiB request limit. Unlike the
minimized profile it honours HTTP_PROXY, HTTPS_PROXY and NO_PROXY. Its
counters are not exposed, so a dropped batch (oversized, rejected or failed) is
reported on stderr at most once a minute with the number of further failures,
never with the endpoint or span data.
The protocol tests decode real protobuf exports and exercise stripped fields, parent relationships, queue saturation, malformed/oversized/chunked/partial replies, redirects and slow/unavailable collectors. The standard collector runner uses an owned digest-pinned container, temporary CA/server identities, verified and rejected TLS, and actual separate producer/consumer processes. It validates the ancestry again in the collector’s output, rather than accepting client counters alone. This runs explicitly in the existing Linux workspace and coverage jobs.
The profile is grounded in the W3C trace-context specification and the OTLP protocol. Its bounded subset and local evidence do not certify external tracing accounts, every OpenTelemetry configuration, production retention or legal compliance.
Deploy to a Linux VM
This page is the shared part of the four provider guides (Hetzner Cloud, DigitalOcean, Oracle Cloud and Google Cloud). It explains what happens on a virtual machine (VM) once you have one, in the order you will do it. Each provider guide covers only what is different: how to create the machine, open the firewall and point DNS.
If you have never run a server before, read this page once from top to bottom first. Nothing here is specific to one company.
What you will end up with
Internet --> 80/443 --> Caddy (HTTPS, certificates) --> your Rullst app (port 3000)
|
database volume
- One VM running Linux (Debian or Ubuntu are the easiest to follow).
- A container engine: Docker or Podman.
- Two containers started by one file: your application and Caddy, a web server that asks for and renews HTTPS certificates by itself.
- Only ports 22 (SSH), 80 and 443 reachable from the internet. The application port (3000) is never opened to the internet; only Caddy talks to it.
1. Generate the deployment files
In your project, on your own computer:
cargo rullst deploy
For a plain Linux VM this writes three files and does not contact any provider:
| File | Purpose |
|---|---|
Dockerfile | Builds the release binary in a build stage (with a separate dependency-cache layer, so later builds are faster) and copies it into a small runtime image that runs as a non-root user. |
compose.yaml | Describes the two containers (your app and Caddy), the private network between them and the volumes. The app reads its settings from .env.production and keeps its data in the app_data volume. It works with docker compose and with podman compose. |
Caddyfile | Tells Caddy to serve the domain in the DOMAIN variable and to forward requests to the application. |
It also adds the Caddy container’s fixed address to [security] trusted_proxies in Rullst.toml (see section 5), and prints the next steps.
Existing files are kept: it writes each file only when it is missing.
Open all three and read them. They are a starting point, and you are the one who deploys them. Do not commit files that contain secrets (see section 5).
The existing generated Dockerfile has a note you should keep in mind: run
db:migrate once as its own job before you start or replace the application
(see migrations).
2. Create the VM and the DNS record
Follow your provider guide. In every case you need:
- Size. A small application (one process, SQLite or a small PostgreSQL) runs on 1 to 2 virtual CPUs and 1 to 2 GB of memory. The first image build can need more memory than running the app, so either add a swap file on the VM (see the note below), build the image on your own computer, or build it in CI and only pull it on the VM. Check each provider’s current pricing and free-tier limits; they change.
- Firewall. Allow inbound TCP 22, 80 and 443 only (and UDP 443 if you want HTTP/3). Everything else stays closed.
- SSH key. Create the VM with your public SSH key and log in with
ssh <user>@<ip-address>. Do not enable password logins. - DNS. At your domain registrar or DNS host create an
Arecord (and anAAAArecord if the VM has an IPv6 address) for your domain pointing to the VM’s public IP address. Caddy can only get a certificate after this record works, so check it withdig +short your-domain.examplefirst.
Swap file (small VMs only). If a build is killed with “out of memory”, create a swap file once:
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile. Add it to/etc/fstabto keep it after a reboot.
3. Install Docker or Podman on the VM
Use the official instructions for your distribution, so you get supported packages:
- Docker Engine and the Compose plugin: https://docs.docker.com/engine/install/
- Podman: https://podman.io/docs/installation (on Debian and Ubuntu,
sudo apt install podman podman-composeis enough; check that your release ships a recent version).
Check that it works:
docker compose version # or: podman compose version
The commands below run docker without sudo. For that, add your user to the
docker group once (sudo usermod -aG docker $USER, then log in again);
members of that group can control the host as root, so add only yourself.
Podman runs rootless as your normal user and needs no group.
4. Get the application onto the VM
There are two simple ways. Pick one.
A. Copy the source and build on the VM. Easiest, but needs enough memory (see the swap note above):
# on your computer, from the project directory
rsync -av --exclude target --exclude .git --exclude '.env*' ./ <user>@<ip>:~/app/
B. Build the image elsewhere and move it. Faster to start on a tiny VM:
# on your computer
docker build -t my-app:2026-10-29 .
docker save my-app:2026-10-29 | gzip | ssh <user>@<ip> 'gunzip | docker load'
scp compose.yaml Caddyfile <user>@<ip>:~/app/
The generated compose.yaml builds the app service from the source
(build: .), which is not on the VM in this case. Add an image: line with
your tag under app: so Compose uses the image you loaded:
services:
app:
image: my-app:2026-10-29
build: .
Then start it without --build in step 6 (docker compose up -d).
If your computer and the VM have different CPU architectures (for example an
Apple Silicon laptop and an x86-64 VM, or an x86-64 laptop and an ARM VM), add
--platform linux/amd64 or --platform linux/arm64 to docker build so the
image matches the VM. A private registry (Docker Hub, GitHub Container
Registry, or your provider’s) also works and replaces docker save/load.
5. Production settings that matter
Put these in a file on the VM named .env.production, next to
compose.yaml (never commit it, and keep it readable only by you:
chmod 600 .env.production). The generated compose.yaml passes it to the
app service, and Compose refuses to start while the file is missing:
| Setting | Why |
|---|---|
RULLST_ENV=production | Turns on the production behavior: Secure cookies, strict headers, and a refusal to start with unsafe configuration. The generated Dockerfile and compose.yaml already set it; keep it in this file too, so deploy:doctor (below) checks the environment you deploy. |
APP_KEY=<long random value> | The application secret that protects sessions. Create one with openssl rand -hex 32. Never reuse a development value. |
DATABASE_URL=... | For SQLite (the starters’ default) the Dockerfile that cargo rullst deploy writes already points it at /app/data/rullst.db, inside the app_data volume; leave it out. A Dockerfile from an earlier deploy or dockerize may lack that line (deploy warns): add ENV DATABASE_URL=sqlite:///app/data/rullst.db?mode=rwc to its last stage, or set it here. For PostgreSQL or MySQL, the connection URL of your database. |
| Other secrets | Mail, payment and AI keys also go here, as environment variables, never in the source or the image. |
Where the CLI is installed (usually your computer, in the project directory),
cargo rullst deploy:doctor --env-file .env.production checks a copy of this
file and Rullst.toml without sending them anywhere; it exits with an error
when, for example, RULLST_ENV or APP_KEY is missing or a placeholder (see
Local Deployment Configuration Diagnostic).
Trusted proxy. Every request reaches your application through Caddy, so
the application sees Caddy’s address as the visitor’s address. Rate limits and
logs would treat all visitors as one. cargo rullst deploy already told
Rullst to trust Caddy in Rullst.toml (if the list existed, it left it
unchanged and printed a warning; then add the address yourself):
[security]
trusted_proxies = ["172.31.250.10"]
Use the fixed address that compose.yaml gives the Caddy container (read it in
the file, and keep the two in step). List only your own proxy: any machine in
that list can choose the client address. Background:
Client addresses behind the proxy.
Health checks. Starters created by cargo rullst new answer GET /health
(the process is alive) and GET /ready (the application is ready for
traffic). Use them for monitoring:
curl -fsS https://your-domain.example/health
Optional monitor token. If you enable the opt-in monitoring endpoint, set
RULLST_MONITOR_TOKENS in the same file and read
Production monitoring first. It is off by default;
do not publish its token.
6. Start it
Caddy serves the domain in the DOMAIN variable. Put it in a file named
.env next to compose.yaml (Compose reads it for the variables in the file;
it holds no secrets), or write DOMAIN=your-domain.example before each
command:
echo 'DOMAIN=your-domain.example' > .env
Rootless Podman cannot listen on ports below 1024 by default. Allow it
once: echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-rullst-ports.conf && sudo sysctl --system. Docker and
rootful Podman need nothing. Rootless containers also belong to your login:
run sudo loginctl enable-linger $USER once so they keep running after you log
out. Docker starts the containers again after a reboot (restart: unless-stopped); with rootless Podman, enable a user service that does it
(for example systemctl --user enable podman-restart.service, or the
Podman Quadlet
units) and check it with one test reboot. To restart everything with
podman-compose, use podman compose down and then podman compose up -d (the
volumes stay): with Podman 5.7 and podman-compose 1.5 on Ubuntu 26.04,
podman compose restart can leave Caddy stopped while it reports success, and
down can print rootless netns: kill network process: permission denied
(Ubuntu’s AppArmor profile for pasta refuses Podman’s stop signal) although
it removes the containers.
On the VM, in the directory that holds the generated files:
docker compose up -d --build # or: podman compose up -d --build
docker compose ps
docker compose logs -f
If you use the Rullst database, run the migrations once, as their own step, before sending traffic (see the note on migrations above):
docker compose run --rm app /app/<binary-name> db:migrate
(<binary-name> is your package name from Cargo.toml. The runtime image has
no shell, so call the binary directly.)
When the DNS record is correct and ports 80 and 443 are open, Caddy requests
a certificate as soon as it starts, and retries until it gets one. Open
https://your-domain.example in a browser. If it does not work, read
docker compose logs caddy: the usual causes are a wrong DNS record, a closed
port, or a typo in the domain.
7. Logs
The application writes its logs to standard output, and the container engine stores them:
docker compose logs --tail 200 app
docker compose logs -f caddy
The app’s first line says where it listens. Because Rullst.toml trusts a
proxy, it also names the two proxy setups. This deployment is the second one:
Caddy runs in its own container and reaches the app over the compose network,
so the app keeps listening on 0.0.0.0 (the HOST that compose.yaml sets),
and only Caddy publishes ports on the VM. Set HOST=127.0.0.1 only when the
proxy runs directly on the host, outside a container network.
On the host, journalctl shows the engine’s own messages
(sudo journalctl -u docker). The default log driver can fill the disk on a
small VM, so limit it: in Docker, set "log-driver": "local" (or a
max-size and max-file option) in /etc/docker/daemon.json and restart
Docker. Podman has --log-opt max-size=... for the same purpose.
8. Back up the database volume
A backup that has never been restored is only a hope. Do both steps once.
SQLite. Use SQLite’s own backup command so the copy is consistent while the app is running (the runtime image has no shell, so run it from a helper container that mounts the same volume, or from the host path of the volume):
docker run --rm -v <volume-name>:/data -v "$PWD":/backup docker.io/library/alpine:3 \
sh -c 'apk add --no-cache sqlite >/dev/null && sqlite3 /data/rullst.db ".backup /backup/rullst-$(date +%F).db"'
The volume is named after the directory that holds compose.yaml: for
~/app it is app_app_data (docker volume ls lists them; with Podman use
podman run and podman volume ls). rullst.db is the file the generated
Dockerfile names in DATABASE_URL; use yours if you changed it.
PostgreSQL or MySQL. Use pg_dump or mysqldump (or your managed
database’s snapshots) on a schedule.
Then copy the backup off the VM (to object storage or another machine) with a
scheduled job such as cron, and every few months restore one into a
temporary copy of the app to prove it works. Provider “snapshots” of the whole
VM are a useful extra, but they are not a substitute for a database-level
backup. Check each provider’s current pricing for snapshots and backups.
9. Update and roll back with image tags
Never deploy the image called latest: you could not say afterwards what is
running. Give every build a tag you can read, such as the date or the Git
commit:
docker build -t my-app:$(git rev-parse --short HEAD) .
Make the app service in compose.yaml use that tag (add an image: line under app:, as in step 4B, or change it), then:
docker compose run --rm app /app/<binary-name> db:migrate # only if there are new migrations
docker compose up -d app
curl -fsS https://your-domain.example/ready
To roll back, change the image: line back to the previous tag and run
docker compose up -d app again. Keep the last few images on the VM
(docker image ls) and do not run docker image prune -a before you are sure
the new version is fine. A rollback does not undo a database migration: write
migrations so the previous version still works with the new schema, and take a
backup (section 8) before any change that removes or rewrites data.
Caddy keeps serving while the app container is replaced: the generated
Caddyfile holds each request for up to 15 seconds while the app is down and
checks /health every 5 seconds, so a quick restart shows as slower responses
rather than errors. A single VM still cannot replace the app without any gap;
if you need zero downtime, you need more than one machine, which is outside
this guide (Kubernetes is one option).
10. Keep the VM healthy
- Apply operating-system security updates regularly
(
sudo apt update && sudo apt upgrade); consider unattended upgrades. - Keep SSH key-only and, if you wish, install
fail2ban. - Watch disk space (
df -h) and memory (free -h). - Keep the VM’s firewall at 22, 80 and 443. Do not publish database ports.
What CI checks
Rullst’s CI runs this guide’s main path (.github/check-vm-deploy.py) on a
GitHub-hosted Ubuntu runner, for the Blank and SaaS starters, with Docker and
with rootless Podman (podman compose with podman-compose):
cargo rullst new, thencargo rullst deploy, then the copy of step 4A and a.env.productionwithRULLST_ENVandAPP_KEY(SaaS adds its billing settings), checked withcargo rullst deploy:doctor;compose up -d --buildwithDOMAIN=localhost, so Caddy uses its own local certificate authority instead of Let’s Encrypt;/healthover HTTPS through Caddy, the HTTP-to-HTTPS redirect, that only Caddy publishes ports, Caddy’s fixed address, and in both containers a read-only root filesystem, no capabilities except Caddy’sNET_BIND_SERVICE, andno-new-privileges;compose run --rm app /app/<binary-name> db:migrate, a page that reads the database (SaaS: registering an account, with a Secure session cookie), and the same after a restart (docker compose restart; Podman recreates the containers withdownandup -d), with the SQLite file still in its volume;- the SQLite backup command of section 8, and no panic in the logs.
It does not cover a real domain, DNS or Let’s Encrypt, a provider account or firewall, an Arm machine, a reboot, path 4B, or PostgreSQL and MySQL.
Where next
- Providers: Hetzner Cloud, DigitalOcean, Oracle Cloud, Google Cloud.
- Other ways to run the same image: Advanced deployment.
- Before real users: the production monitoring and security architecture pages.
Deploy on Hetzner Cloud
Hetzner Cloud sells simple virtual machines (“Cloud Servers”) in European and some other regions. This guide covers only the Hetzner-specific steps. Everything after you can log in to the VM is the same on every provider and lives in Deploy to a Linux VM: read it alongside this page.
Official documentation: https://docs.hetzner.com/cloud/. Check the provider’s current pricing, locations and limits before you choose; they change.
1. Create the server
In the Cloud Console:
- Create a project, then Add Server.
- Location: pick the one closest to your users.
- Image: Debian or Ubuntu LTS.
- Type: a small shared-CPU type with 2 GB of memory or more is comfortable
for one small app. Both x86-64 and Arm (Ampere) types exist; an Arm type
needs a
linux/arm64image (see step 4 of the shared guide). - SSH keys: add your public key (
~/.ssh/id_ed25519.pub). Do not choose a password login. - Firewalls: attach the firewall you create in the next section.
- Backups are an optional paid add-on for the whole server; they do not replace database backups.
Note the server’s public IPv4 address (and IPv6, if you want it).
2. Firewall: 22, 80 and 443 only
Under Firewalls, create one with these inbound rules and apply it to the server:
| Protocol | Port | Source |
|---|---|---|
| TCP | 22 | your own IP address if it is stable, otherwise any |
| TCP | 80 | any |
| TCP | 443 | any |
Hetzner firewalls are stateful, so replies are allowed automatically. Port 80 must stay open: Caddy uses it to obtain and renew certificates. Add UDP 443 as well if you want HTTP/3; without it browsers use HTTPS over TCP.
3. DNS
Create an A record for your domain with the server’s IPv4 address (and an
AAAA record for IPv6). You can use Hetzner DNS or any other DNS provider.
Check with dig +short your-domain.example.
4. Log in and continue
ssh root@<ip-address>
Create a normal user with sudo rights and use it from now on (root login is
convenient but risky):
adduser deploy && usermod -aG sudo deploy
mkdir -p /home/deploy/.ssh && cp ~/.ssh/authorized_keys /home/deploy/.ssh/ \
&& chown -R deploy:deploy /home/deploy/.ssh
Then follow Deploy to a Linux VM from
step 3: install Docker
or Podman, copy or build the app, set the
production settings,
start it with compose up, and set up
backups,
updates and rollback.
Hetzner notes
- Logs stay on the server (see Logs); the Cloud Console does not collect them.
- Snapshots of the server are available in the Console and are useful before a risky change. They are not a database backup.
- The Cloud Console’s Rescue system and console access help if you lock yourself out of SSH.
Deploy on DigitalOcean
DigitalOcean calls its virtual machines “Droplets”. This guide covers only the DigitalOcean-specific steps. After you can log in, everything is the same on every provider and lives in Deploy to a Linux VM: read it alongside this page.
Official documentation: https://docs.digitalocean.com/products/droplets/. Check the provider’s current pricing, regions and limits; they change.
1. Create the Droplet
In the control panel, choose Create, then Droplets:
- Region: closest to your users.
- Image: Debian or Ubuntu LTS.
- Size: a Basic (shared CPU) Droplet with 2 GB of memory or more is comfortable for one small app. The smallest sizes can run the app but may run out of memory while building the image; see the swap note in the shared guide, or build the image elsewhere.
- Authentication: choose SSH Key and add your public key.
- Optional Backups are a paid whole-Droplet feature; they do not replace database backups.
Note the Droplet’s public IPv4 address.
2. Firewall: 22, 80 and 443 only
Under Networking, Firewalls, create a Cloud Firewall with these inbound rules, leave outbound rules at their defaults, and apply it to the Droplet:
| Type | Protocol | Port | Sources |
|---|---|---|---|
| SSH | TCP | 22 | your IP address if stable, otherwise all |
| HTTP | TCP | 80 | all IPv4 (and IPv6 if you use it) |
| HTTPS | TCP | 443 | all IPv4 (and IPv6 if you use it) |
Port 80 must stay open: Caddy uses it to obtain and renew certificates. Add a UDP 443 rule as well if you want HTTP/3; without it browsers use HTTPS over TCP.
If you also run a host firewall such as ufw, allow the same three ports
there. Note that Docker publishes container ports by changing the firewall
rules itself, which can bypass ufw; the cloud firewall above is the reliable
one.
3. DNS
Add the domain under Networking, Domains (or use any other DNS host),
and create an A record pointing to the Droplet’s IP address. Check with
dig +short your-domain.example.
4. Log in and continue
ssh root@<ip-address>
Create a normal user with sudo rights (adduser deploy && usermod -aG sudo deploy, then copy ~/.ssh/authorized_keys to its .ssh directory) and use it
from now on. Then follow Deploy to a Linux VM from
step 3.
DigitalOcean notes
- Logs stay on the Droplet (see Logs).
- Snapshots are available before risky changes and are not database backups.
- A Recovery Console in the control panel helps if SSH stops working.
- Managed databases are an alternative to a database on the Droplet: use the
connection URL as
DATABASE_URLand allow only your Droplet to connect.
Deploy on Oracle Cloud
Oracle Cloud Infrastructure (OCI) offers an Always Free tier that includes
small virtual machines. The Always Free Ampere machines use Arm processors
(aarch64), so a Rullst image for them must be built for linux/arm64. This
guide covers the OCI-specific steps. After you can log in, everything is the
same as on every provider and lives in
Deploy to a Linux VM: read it alongside this page.
Official documentation: https://docs.oracle.com/en-us/iaas/Content/home.htm. Free-tier shapes, quantities and availability change and differ by region and account: check Oracle’s current Always Free documentation before you rely on them. Free Arm capacity in a popular region is sometimes unavailable; try again later or pick another availability domain.
Arm or x86?
- Ampere A1 (Arm). Build for
linux/arm64. Rust and the generatedDockerfilework natively on Arm, so the simplest way is to build on the VM itself withdocker compose up -d --build(step 4A of the shared guide). Ampere VMs are usually fast enough for this. - Building elsewhere. On an x86-64 computer, build for Arm with
docker buildx build --platform linux/arm64 -t my-app:<tag> --load .. This needs QEMU emulation (Docker Desktop includes it; on Linux installqemu-user-static) and an optimized Rust build under emulation is slow. For anything beyond a trial, build on the VM or on an Arm CI runner. - x86 micro shape. Oracle’s x86 free VM is very small (see its current
memory); it cannot comfortably compile Rust. Build the image elsewhere with
--platform linux/amd64and move it (step 4B of the shared guide).
1. Create the instance
In the OCI Console, Compute, Instances, Create instance:
- Image: Ubuntu LTS (or Oracle Linux; this guide assumes Debian-family commands).
- Shape: choose Ampere (
VM.Standard.A1.Flex) and set the OCPUs and memory within the Always Free limits shown in the console. - Networking: use the default VCN and a public subnet with a public IPv4 address.
- SSH keys: paste your public key. The default login user is
ubuntu(oropcon Oracle Linux).
2. Firewall: two places
OCI blocks traffic in the cloud network and the image may block it on the VM. You must open both.
In the cloud network: open the VCN’s Security Lists (or the instance’s
Network Security Group) and add ingress rules (source 0.0.0.0/0, TCP) for
destination ports 22, 80 and 443 (and UDP 443 if you want HTTP/3; without it
browsers use HTTPS over TCP).
On the VM: Oracle’s Ubuntu images ship with iptables rules that reject
other ports. Allow 80 and 443 before the final reject rule and save them:
sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 80 -j ACCEPT
sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 443 -j ACCEPT
sudo apt install -y iptables-persistent && sudo netfilter-persistent save
Check the rule numbers with sudo iptables -L INPUT --line-numbers first: the
new rules must come before the REJECT rule. On Oracle Linux use firewalld
(sudo firewall-cmd --permanent --add-service={http,https} and --reload).
Docker adds its own rules for published ports, so the cloud rules from the
first part are the ones that decide what the internet can reach.
3. DNS
Create an A record pointing to the instance’s public IP address. For a stable
address, reserve a public IP instead of the ephemeral one, so it does not
change if you recreate the instance. Check with dig +short your-domain.example.
4. Log in and continue
ssh ubuntu@<ip-address>
Then follow Deploy to a Linux VM from
step 3. If you move
an image from another computer, make sure it was built for linux/arm64
(docker image inspect my-app:<tag> --format '{{.Architecture}}' should print
arm64).
Oracle Cloud notes
- Logs stay on the instance (see Logs).
- Free accounts and idle instances can be reclaimed under Oracle’s current policy; read the Always Free terms and keep your own off-VM backups.
- Boot-volume backups are available in the console; they are not database backups.
Deploy on Google Cloud (Compute Engine)
This guide runs your app on a Compute Engine virtual machine. Google Cloud’s
free tier has included a small e2-micro instance in selected US regions; the
terms, regions and limits change, so check Google’s current “Free Tier” page
before you rely on it. An e2-micro has very little memory, so do not
compile Rust on it: build the image on your own computer or in CI, and only
run it on the VM (step 4B of the shared guide, or a registry). After you can
log in, everything is the same as on every provider and lives in
Deploy to a Linux VM: read it alongside this page.
Official documentation: https://cloud.google.com/compute/docs.
1. Create the VM
In the Google Cloud console (or with the
gcloud CLI), enable the Compute Engine API for a project, then Compute
Engine, VM instances, Create instance:
- Region and zone: for the free tier, one of the eligible regions listed in Google’s current documentation; otherwise the closest to your users.
- Machine type:
e2-microfor a trial. For a real app, a larger type (for examplee2-smallore2-medium) is much more comfortable: check the current pricing. - Boot disk: Debian or Ubuntu LTS. Keep the disk type and size within the free-tier limits if you want to stay free.
- Firewall: tick Allow HTTP traffic and Allow HTTPS traffic (see the next section; SSH is allowed by the default rules).
- Networking: under network interfaces, use a static external IP address (reserve one) so the DNS record does not break after a restart. Check the current price of reserved addresses.
2. Firewall: 22, 80 and 443 only
Compute Engine firewall rules apply to the whole VPC network. The default
network already allows SSH (22). The HTTP and HTTPS checkboxes create rules for
instances with the network tags http-server and https-server. To control
this yourself:
gcloud compute firewall-rules create allow-web \
--network default --direction INGRESS --allow tcp:80,tcp:443 \
--target-tags web --source-ranges 0.0.0.0/0
gcloud compute instances add-tags <instance-name> --tags web --zone <zone>
Also review the default network’s other rules (for example the one that allows
all internal traffic or RDP) and remove what you do not use. Port 80 must stay
open: Caddy uses it to obtain and renew certificates. For HTTP/3 also allow
udp:443; without it browsers use HTTPS over TCP.
3. DNS
Create an A record pointing to the reserved external IP address (in Cloud DNS
or any DNS host). Check with dig +short your-domain.example.
4. Log in and continue
gcloud compute ssh <instance-name> --zone <zone>
or use the SSH button in the console. Then follow
Deploy to a Linux VM from
step 3. On a small
machine, build elsewhere and transfer the image. An alternative to
docker save/load is Google’s Artifact Registry
(https://cloud.google.com/artifact-registry/docs): push the tagged image
there, and pull it on the VM.
Google Cloud notes
- Logs from your containers stay on the VM (see Logs). The Ops Agent can ship host logs to Cloud Logging if you want that; it is optional and may cost money.
- Snapshots of the persistent disk are available, including schedules. They are not database backups: keep a database backup too.
- Compute Engine can use a managed database (Cloud SQL) instead of a database
on the VM; use its connection URL as
DATABASE_URL. - Cloud Run and GKE are different services with their own guides; for a Kubernetes outline see Advanced deployment.
Advanced deployment
This page is for people who already run servers and want to deploy a Rullst application with a tool the CLI does not know about: Nix, Buildah, Podman Quadlet or Kubernetes.
Read this first. Everything below is illustrative.
cargo rullstdoes not generate, run or test any of these files, and the Rullst project does not validate these snippets against every tool version. They show where Rullst’s own requirements (the binary name, the port, the environment, the health routes, the trusted proxy) fit. Check each tool’s official documentation, adapt the names, and test in a staging environment. If you are new to servers, start with Deploy to a Linux VM instead.
What a Rullst application needs from any platform
| Need | Detail |
|---|---|
| A binary | cargo build --release --locked produces one executable named after your package. It needs a glibc Linux (or a musl build you have tested) and CA certificates. |
| Environment | RULLST_ENV=production, APP_KEY (a long random secret), DATABASE_URL, and any provider keys, as environment variables. |
| Address and port | In production the server listens on 0.0.0.0; set PORT (the generated image uses 3000) and, behind a proxy on the same host, HOST=127.0.0.1. |
| Probes | GET /health (process alive) and GET /ready (ready for traffic). Starters mount both. A redirect to login is not a health signal. |
| Trusted proxy | Behind any reverse proxy or ingress, set [security] trusted_proxies in Rullst.toml (or Server::trusted_proxies) to the proxy’s address range, and only that. See Client addresses behind the proxy. |
| Migrations | Run <binary> db:migrate once per release, before new replicas serve traffic, or opt in to startup migrations. |
| Writable state | A SQLite file, uploads and similar state need a persistent writable directory. The generated image runs as UID/GID 10001. |
Nix
A Nix flake
can build the release binary reproducibly. Use rustPlatform.buildRustPackage
(see the nixpkgs Rust documentation):
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-<release>"; # pin the release you test
outputs = { self, nixpkgs }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
packages.${system}.default = pkgs.rustPlatform.buildRustPackage {
pname = "my-app"; # your [package].name
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.openssl ]; # only if a dependency needs it
};
};
}
nix build # result/bin/my-app
Commit flake.lock so the inputs stay pinned. On NixOS you can then run the
binary from a systemd.services.my-app unit with Environment= or
EnvironmentFile= for the settings above; keep secrets out of the Nix store
(use a file outside it, such as one managed by sops-nix or agenix). The static
static/ directory and Rullst.toml must be installed next to, or referenced
by, the service.
Buildah (rootless image build)
Buildah builds OCI images without a daemon and without
root. It can build the Dockerfile that cargo rullst deploy (or
cargo rullst dockerize) writes:
buildah bud --layers -t my-app:$(git rev-parse --short HEAD) .
buildah push my-app:<tag> docker://registry.example.com/me/my-app:<tag>
--layers keeps the dependency-cache stage between builds. Rootless builds need
/etc/subuid and /etc/subgid entries for your user (see the
Buildah tutorials).
For a different CPU architecture add --platform linux/arm64 (this needs QEMU
emulation or a native Arm builder). Use the resulting image with Podman,
Docker or Kubernetes. Image and Kubernetes names must be lowercase and use -,
not _.
Podman Quadlet (systemd unit for the container)
Quadlet
lets systemd run a Podman container from a small .container file.
For a rootless service put it in ~/.config/containers/systemd/my-app.container
(system-wide: /etc/containers/systemd/):
[Unit]
Description=My Rullst application
After=network-online.target
[Container]
Image=registry.example.com/me/my-app:2026-10-29
PublishPort=127.0.0.1:3000:3000
EnvironmentFile=%h/my-app/.env.production
Volume=%h/my-app/data:/app/data:Z,U
HealthCmd=none
[Service]
Restart=always
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user start my-app.service
loginctl enable-linger "$USER" # keep it running when you log out
The generated runtime image has no shell, so container-level HealthCmd
checks do not work; use HealthCmd=none and probe /health and /ready from
outside (your reverse proxy or monitoring). Put a reverse proxy such as
Caddy in front for HTTPS, publish the application port to 127.0.0.1 only, and
set trusted_proxies to the address the proxy’s connections arrive from inside
the container (with rootless port forwarding this can be a gateway address
rather than 127.0.0.1), and nothing broader. :U makes Podman chown the
volume to the container user (10001). Pin the image to a tag or digest and
change it to update; to roll back, set the previous tag and restart the unit.
Kubernetes
You can write the manifests yourself, or start from cargo rullst make:k8s
(see Tutorial 25); the generated files
also need review. The outline:
apiVersion: apps/v1
kind: Deployment
metadata: { name: my-app }
spec:
replicas: 2
selector: { matchLabels: { app: my-app } }
template:
metadata: { labels: { app: my-app } }
spec:
containers:
- name: my-app
image: registry.example.com/me/my-app@sha256:<digest>
ports: [{ containerPort: 3000 }]
env:
- { name: RULLST_ENV, value: production }
- name: APP_KEY
valueFrom: { secretKeyRef: { name: my-app, key: app-key } }
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: my-app, key: database-url } }
startupProbe:
httpGet: { path: /health, port: 3000 }
failureThreshold: 30
periodSeconds: 2
livenessProbe:
httpGet: { path: /health, port: 3000 }
readinessProbe:
httpGet: { path: /ready, port: 3000 }
---
apiVersion: v1
kind: Service
metadata: { name: my-app }
spec:
selector: { app: my-app }
ports: [{ port: 80, targetPort: 3000 }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: { name: my-app }
spec:
ingressClassName: nginx
tls: [{ hosts: [app.example.com], secretName: my-app-tls }]
rules:
- host: app.example.com
http:
paths:
- { path: /, pathType: Prefix, backend: { service: { name: my-app, port: { number: 80 } } } }
Points to check against the Kubernetes documentation:
- Probes. Liveness on
/health(process only, so a database outage does not cause restart loops) and readiness on/ready. The default routes return 200 while the process serves; the lifecycle-aware router in Tutorial 25 reports startup, dependencies and drain on/ready. - Trusted proxy. The application sees the ingress controller’s pod address,
not the visitor’s. Set
trusted_proxiesinRullst.tomlto the pod network range of your ingress controller (look it up in your cluster) and nothing broader. - Migrations. Run
db:migrateas a KubernetesJob(or an init step with a lock) before rolling out a new Deployment, not in every replica unless you enabled startup migrations. - Secrets, storage and security. Use Secrets (or an external secrets
manager) for credentials, a database that is not a file in a replicated Pod,
a non-root
securityContext, resource requests/limits, a PodDisruptionBudget and a NetworkPolicy. See the checklist at the end of Tutorial 25. - Rollback.
kubectl rollout undo deployment/my-appreturns to the previous ReplicaSet; it does not undo database migrations.
Where next
- Deploy to a Linux VM and the provider guides.
- Two-Replica Deployment Acceptance for what the project itself tests for more than one instance.
- Local Deployment Configuration Diagnostic.
Two-replica deployment acceptance
This v13 candidate exercises two independent Rullst application processes behind
a digest-pinned Caddy proxy and an owned Redis server. The local contract
passed, alongside that source’s 242 Core and 163 Security library tests,
strict lints and five detected body-lifetime mutations. Hosted workspace and
installed-archive checks subsequently passed in
PR #224; the final release campaign
remains separate. It is a deployment test
and reference boundary, not a new load-balancer crate or an automatic cloud/VPS
deployment feature. The files cargo rullst deploy generates (one app container
behind Caddy) have their own CI check, .github/check-vm-deploy.py, described
in Deploy to a Linux VM.
Run on Linux with Docker and the repository Rust toolchain:
python3 .github/check-deployment-proxy.py
The runner builds an explicitly ignored test fixture, creates temporary application
directories and fresh fixture keys, and starts uniquely named containers. Every
listener is loopback-only. No real account, application data, cloud credentials,
SSH changes or Docker socket mounted inside a container are required. The proxy
uses host networking solely to reach the owned loopback listeners; this is not
container network isolation. Its root filesystem and configuration are read-only,
it runs as an unprivileged UID, and its only retained capability is
NET_BIND_SERVICE, required by the official image binary’s file capability.
The runner removes its own processes/containers on exit. Redis state is disposable.
Executable boundary
| Case | Observed contract |
|---|---|
| Startup | Both dependency gates start unavailable. Core denies application admission; the proxy does not return a successful application response from an unready replica. |
| Availability | Once ready, both independent processes serve. Marking one dependency unavailable removes that replica after health-check propagation; making it ready restores participation. |
| Shared request budget | Both processes explicitly require the distributed Redis limiter and consume the same six-request fixture budget. A seventh request fails, including direct requests with forged forwarding headers. Redis loss returns 503, with a bounded application timeout and no local fallback. |
| Forwarded metadata | Core derives its default identity from the transport socket. The proxy has no trusted inbound forwarding hops, sanitizes X-Forwarded-For, and removes Forwarded. Forged headers cannot replace the identity. |
| HTTP controls | The real Server production baseline supplies secure headers and CSRF enforcement. A route accepts a 1,024-byte body with a valid double-submit token and rejects 1,025 bytes. Missing CSRF is rejected. |
| In-flight response | A real streaming response returns its first chunk, remains counted during drain and finishes only when explicitly released. The draining replica stops receiving new application work while another replica continues serving. |
| WebSocket | The Security origin policy rejects missing/foreign origins. A valid connection exchanges frames through Caddy. Application-owned drain sends a restart close frame; ordinary HTTP body accounting is not used as WebSocket lifetime accounting. |
| Process termination | After its stream completes, the drained process shuts down within the fixture’s deadline. Killing the remaining process causes the proxy to report no healthy upstream. |
The fixture’s routing and control endpoints are test code. Its management channel
is the parent-owned stdin pipe, not an HTTP administration route. There are no
user accounts or domain authorization claims. A socket-level quota behind a proxy
conservatively combines clients from that peer; real per-user quotas must use an
authenticated application identity, or an explicitly reviewed trusted-hop policy
such as Core’s Server::trusted_proxies, which this fixture does not enable.
Raw forwarding headers never supply a tenant, role or user identity.
HTTP drain correction
ApplicationLifecycle now retains an admitted request through its handler and
the ordinary response body’s data/trailers, releasing it exactly once on
completion, error or drop. Previously, the guard was released when the handler
returned response headers, which could report zero in-flight requests while a
stream remained active. The regression was observed failing before the fix.
The body wrapper preserves frames, size hints and end-of-stream state without buffering. Local tests cover a pending body, multiple data/trailer frames, errors, cancellation and an empty body. This is the lifetime observed at the middleware boundary; buffering by another layer can consume that body before bytes reach the client. It is not proof of TCP acknowledgement or successful client receipt. Applications still terminate detached work and upgraded connections separately.
wait_for_drain has an explicit bounded wait and typed timeout. Server’s Axum
graceful-shutdown future itself does not impose a universal kill deadline; the
application/supervisor owns that deadline and the choice to abort unfinished work.
Production differences
The fixture uses cleartext HTTP on loopback, fixed test budgets and aggressive health intervals. It does not validate public TLS/ACME, a CDN, physical host or region failure, Redis failover, persistence rollback, autoscaling or production traffic capacity. Health changes take time to propagate; intermediate errors can occur. No zero-downtime or exactly-once claim follows from a passing run.
The profile disables automatic proxy retries. Application retries of mutations still require domain idempotency and durable transactional state. Multi-host authentication must choose suitable shared storage; placing a process-local or shared-local SQLite store behind a proxy does not make it cross-host durable. The shared passkey candidate addresses one such state boundary and retains its own acceptance requirements.
The offline deploy:doctor inspects only a local
configuration snapshot. A generated multi-replica profile and additional
deployment topologies remain follow-up work. No firewall, SSH, IAM or
host security configuration is changed by this increment.
The reviewed proxy behavior follows Caddy’s official reverse-proxy documentation, including health checks, forwarding headers and upgraded streams. The fixture configuration and runner pin the actual tested image independently of future upstream releases.
Local deployment configuration diagnostic
The v13 deploy:doctor candidate inspects a bounded local configuration snapshot
without starting the application, invoking other programs, contacting a provider
or changing files. Its focused CLI tests include a real generated SaaS profile.
Its source merged in PR #225 after
hosted checks. The initially skipped archive gate subsequently passed in
exact-commit validation.
That retrospective repair and the remaining final release campaign are recorded
in the delivery plan.
# Explicit file snapshot, independent of this terminal's environment:
cargo rullst deploy:doctor --env-file .env.production --json
# Inspect a selected TOML and the three allowlisted process variables:
cargo rullst deploy:doctor --config Rullst.production.toml --process-env
# Staging is an explicit target, not a production-mode alias:
cargo rullst deploy:doctor --env-file .env.staging --target staging
Run the command from the intended application’s directory. Without --config,
it reads only ./Rullst.toml, or uses Core defaults when that file is absent.
An explicitly selected file must exist. Relative file paths resolve from the
current directory; absolute paths are allowed without printing their names.
This is useful for database-free starters that have no TOML file.
Choose exactly one environment source:
--env-filereads the explicitly selected literal dotenv file. The shell’s environment is not merged with it, and no parent/default.envis searched.--process-envreads onlyRULLST_ENV,APP_ENVandAPP_KEY. It never reads dotenv files or provider credentials from the process environment.- With neither flag, environment/key inspection is explicitly incomplete and exits unsuccessfully. Ambient settings cannot silently make the report pass.
Resolution within that snapshot uses Core’s RULLST_ENV, legacy APP_ENV, then
TOML [app].env precedence. Conflicting legacy variables receive a review note.
The requested target defaults to production; staging must be selected
explicitly. Invalid or blank selected values do not fall through to a lower
priority value. The running application may load or override configuration
differently: the report does not observe that process.
Checks and output
Core’s configuration validator checks browser-policy syntax, exact CORS
origins/signed-webhook paths and [security] trusted-proxy networks. A zero TOML
port is reported. Custom CSP, explicit browser-policy exceptions, CSRF webhook
exemptions and configured trusted-proxy networks are flagged for application
review; a syntactically valid CSP may still be weak. Unknown Core configuration
fields receive a typo/application-specific-setting review note.
The key check catches missing/short keys, known example/mock prefixes, control
characters and single-symbol keys. It intentionally does not duplicate Auth’s
complete validation or infer cryptographic randomness. Supply an explicit
APP_KEY in the chosen environment source; legacy TOML key fallback is outside
this diagnostic’s supported profile. Actual Auth validation, secure random
generation, secret custody and rotation remain necessary.
JSON uses rullst.deployment-diagnostic.v1, fixed check codes, PASS, FAIL,
REVIEW or NOT_INSPECTED, and fixed remediation guidance. It always includes
deployment_verified: false and lists controls that were not inspected.
inspection_complete concerns only the selected local profile. Exit zero means
the selected profile was inspected without local failures; review notes remain
visible. It is not approval to deploy. A nonzero exit accompanies incomplete
selection, invalid inputs, detected errors or output failures.
The report excludes input paths, values, database URLs, secrets and hashes of secret-bearing files. Parser and filesystem error details are withheld. Text output has the same observations and boundaries as JSON. Redirect JSON yourself if a local report file is needed; the command does not create one automatically.
Input boundary
Files must be regular UTF-8 files of at most 64 KiB. Links, reparse points and special files are rejected, including linked supplied parent directories. The current directory’s OS aliases are resolved once, which supports macOS temporary directories. Parent directories must be trusted against concurrent replacement; this inspection is not a filesystem sandbox or atomic snapshot of all application state. The parser rejects duplicate TOML fields.
The literal dotenv profile permits blank/comment lines and unique ASCII
KEY=value assignments, optionally prefixed by export , with simple matching
single/double quotes and whitespace-separated trailing comments. A # embedded
in an unquoted value remains part of the value. There are at most 512 assignments,
128 bytes per key and 8192 bytes per value. Interpolation ($), backslash escapes,
multiline/concatenated quotes, control characters and duplicate assignments are
rejected. Arbitrary dotenv syntax is not silently approximated. Other literal
keys are parsed only to validate the file and are omitted from the report.
This command does not verify provider credentials, mounted authorization or middleware, webhook signatures, TLS, trusted proxies, distributed budgets/replay state, readiness/drain, storage durability, host permissions or backup recovery. Use the deployment acceptance fixture and tests of the actual application for those boundaries. Neither a local report nor that fixture certifies the security of a cloud account or VPS.
Privacy and age assurance roadmap
Status: v13 P0, initial age-assurance foundation (first published in the
13.0.0-alpha.1 prerelease on 11 October 2026), reviewed 17 September 2026; persistence, consumer and packaging engineering updated 20 September 2026. The crate implements
risk policies, bound challenges, signed threshold attestations, explicit
outcomes and asynchronous replay-store contracts. The optional shared-local
SQLite adapter adds atomic persisted consumption, quota and clock rollback
checks. The optional PostgreSQL candidate adds shared claims across application
hosts with explicit initialization and real-database concurrency, cancellation,
expiry, configuration, restricted-role and server-restart tests; the optional
consent-postgres adapter shares consent state the same way. Final release
acceptance, broader privacy workflows and concrete live age providers remain
open. Native declarations, independent optional
consent and authenticated SaaS preference/profile-export journeys now have
focused test evidence; v13 removed the generated LMS journeys with the complete
Academy scaffold. These do not implement every roadmap responsibility. This
engineering update does not revalidate the legal sources.
Rullst should generate applications with privacy-preserving defaults and reusable, testable controls. It must not advertise automatic worldwide legal compliance. The applicable rules depend on the operator, audience, processing purpose, territory and service; organizational duties cannot be inferred from a Cargo feature. This is an engineering plan requiring jurisdiction-specific legal review before a profile is presented as supported.
Existing foundations and the missing boundary
The ORM already offers field encryption. Auth/Security provide identity,
authorization and redaction primitives. The v12 LMS privacy templates record
school-scoped policies, nominal Adult/Minor bands, guardian-consent records
and bounded export/delete request processing. They do not independently verify
a person’s age or a guardian’s authority, and their privacy worker requires an
application adapter to perform actual export/deletion. v13 removed these
templates with the complete Academy scaffold; the LMS example (the former v13
LMS starter) has no privacy lifecycle.
Applications generated with those templates keep their code; reuse needs an explicit migration. Adding a consent row, encrypting a field or completing a mock job does not establish lawful processing or prove that downstream copies were erased.
Package decision
Use the optional rullst-privacy crate with its age-assurance feature
and age_assurance module. Age assurance belongs with purpose, minimization, retention and child
protection policy; Auth consumes its evidence when deciding access. A separate
age-only crate is unnecessary for the first bounded contract.
| Component | Proposed responsibility |
|---|---|
rullst-privacy | Versioned purposes/policies, lawful-basis metadata, consent and withdrawal, rights-request orchestration, retention/hold contracts, guardian evidence and age-assurance policy/receipts. Static-dispatch adapters and typed errors. |
rullst-auth | Bind evidence to the authenticated session/subject and enforce the requested age-restricted action server-side. An age receipt never replaces authentication, tenant scope or ordinary authorization. |
rullst-security | Reuse encryption, abuse controls, signature primitives and minimized audit. Do not duplicate cryptography or bury the privacy domain inside WAF middleware. |
rullst-orm and Core | Optional persistence and bounded jobs. Application adapters enumerate actual stores; no reflection-based discovery or automatic traversal of arbitrary data. |
rullst-connect | Consume a reviewed issuer’s identity/age claims only when their semantics are established; an OAuth login alone proves no age. |
cargo-rullst, Nexus and Studio | Generate explicit application configuration, accessible preferences/rights flows and authorized, minimized operational views. Never display selfies or identity documents as telemetry. |
rullst-ai | Optional explanations with approved, minimized context. An LLM’s visual guess must not authorize age-restricted access or establish guardianship. |
The base crate must not require a database, camera, remote provider, inference runtime or new default Core dependency. Provider/persistence integrations stay feature-gated. The package candidate is included in the v13 release-order manifest and has explicit optional umbrella features, with no Core dependency. The package was first published in the 13.0.0-alpha.1 prerelease on 11 October 2026. Live providers remain outside this independent foundation’s advertised scope. A future local vision engine may need its own package/model lifecycle; that is separate from this first policy and adapter contract.
Global policy coverage
Profiles are versioned implementation targets, not legal certificates. Each needs authoritative references, applicability assumptions, effective dates, last-review date, reviewer, tested controls and explicit gaps. Do not select law solely from language, IP geolocation or a country dropdown. More than one regime may apply; unresolved requirements need an operator decision, not an automatic “strictest country wins” rule.
| Wave | Jurisdiction and review scope | Initial source |
|---|---|---|
| First | Brazil: LGPD and child-protection/ECA Digital requirements | LGPD, ANPD age-assurance guidance programme |
| First | EU/EEA: GDPR, country-specific child-consent ages, applicable ePrivacy and DSA duties | GDPR, EDPB age assurance |
| First | UK: UK GDPR/DPA, PECR, Children’s Code and applicable Online Safety Act duties | ICO Children’s Code, Ofcom age assurance |
| First | US: CCPA as amended by CPRA where applicable, COPPA, plus a maintained state/biometric-law applicability inventory | California DOJ, FTC COPPA amendments |
| Expansion | Canada: PIPEDA and provincial regimes, including Quebec; Australia: Privacy Act/APPs and applicable child-safety rules | Canadian OPC, Australian OAIC |
| Expansion | Japan: APPI; India: DPDP Act/rules with phased commencement tracked explicitly | Japanese PPC, India’s rules and commencement documents |
Additional named review targets are Switzerland’s FADP, South Africa’s POPIA, Singapore’s PDPA, South Korea’s PIPA and China’s PIPL, followed by the countries actually served in Latin America and the Middle East. Their detailed mappings are not reviewed or supported by this document. A “global” label must never hide missing jurisdictions, sector rules, localization or cross-border duties.
Rules also evolve within a release cycle. The ANPD opened consultation on age assurance guidance in May 2026; a draft must not be treated as a final technical mandate. The FTC’s February 2026 age-verification enforcement policy has conditions and is not a general exemption from COPPA. Profiles need legal-update review separate from framework version updates. ANPD consultation, FTC policy statement.
Controls that can be automated
- Purpose-bound processing: explicit data-category and purpose inventory, legal-basis metadata, processor destinations and retention rules. Consent is one possible basis, not a substitute for every basis or a blanket terms checkbox.
- Conservative defaults: unnecessary tracking, advertising, remote embeds and personal-data collection disabled. Essential session/CSRF controls stay distinct from optional analytics. Only activate integrations allowed by the reviewed policy and current preferences.
- Consent and choices: purpose-specific, versioned receipts; equally usable refusal/withdrawal; server-side enforcement; age-appropriate notices. Treat GPC as the relevant sale/sharing opt-out signal under an applicable profile, not as a universal consent value. California describes this obligation for covered businesses in its CCPA guidance.
- Rights workflows: authenticated access/export/correction/delete requests, applicable objection/restriction/opt-out paths, representative authority, bounded deadlines, retries and delivery evidence. Verify identity proportionately and prevent cross-user/tenant exports. Minimize any retained verification data.
- Retention and erasure: explicit adapters for SQL, files, caches, search and vector indexes, queues, mail, AI history and processors. Coordinate racing jobs so deleted data is not recreated. Legal holds have bounded purpose, restricted access and review; soft deletion is not erasure. Track backup expiry and reapply erasure records on restore. Pseudonymization is not a claim of irreversible anonymization.
- Evidence and operations: minimized audit, data-flow/processor inventory, DPIA/RIPD inputs, incident workflow and policy-version reports. Export a control assessment with “unconfigured/unsupported” states, never a universal “legally compliant” badge.
The operator still owns applicable-law decisions, lawful purpose, notices, contracts, transfer mechanisms, DPO/representative duties where required, responses to people/regulators and incident handling. The framework can record and enforce configured decisions; it cannot make those decisions correct.
Age assurance contract
Keep three policies separate: eligibility for a particular service/action, capacity to consent to particular processing, and verified guardian authority. There is no universal 18-year threshold for every application or consent flow. GDPR Article 8 concerns a particular consent-based information-society-service context and allows national thresholds within its bounds; it is not a universal account-registration age. GDPR.
An Academy may support minors with appropriate protections. Its risk assessment must determine when stronger evidence is needed; a camera check must not become mandatory merely to read a lesson. Guardian consent cannot override a legally prohibited activity. A recorded guardian relationship needs its own evidence, scope, expiry and withdrawal contract.
| Method | Meaning and planned treatment |
|---|---|
| Self-declared age/band | Low-assurance declaration, clearly labeled. Never silently promote it to verified evidence or use it where the policy requires a stronger method. |
| Facial age estimation | Probabilistic estimate from a specialized provider or evaluated local model; not exact age or identity. Optional adapter, subject to the gates below. |
| Verified age attribute | A reviewed digital credential, provider/document check or other appropriate method returning the minimum threshold/band needed. Authenticity and subject binding must be verified. |
Facial estimation is not inherently useless: Ofcom lists it among methods capable of being highly effective, subject to technical accuracy, robustness, reliability and fairness. A photo upload alone does not establish these properties. NIST’s evaluation reports demographic and image-quality effects. Ofcom guidance, NIST evaluation.
The planned contract must:
- distinguish passed, below-threshold, inconclusive, unavailable, expired and offline-mock results; inconclusive/unavailable cannot grant restricted access;
- offer an accessible alternative and appeal/review flow, without treating a provider outage or lack of camera as permission to bypass the required check;
- use a validated challenge-age margin near the threshold and an alternative method for uncertain cases; a raw model confidence score is not a calibrated probability or independently verified accuracy;
- test presentation/replay attacks, borrowed photos, synthetic media and capture injection; liveness is one control and needs evidence of its own;
- bind issuer, audience, session/subject, tenant, policy version, threshold, method, expiry and a one-use challenge to authenticated provider evidence; verify signatures/callbacks, prevent cross-context replay and define key rotation/revocation. Browser-authored booleans or scores are never trusted;
- retain only the policy result and necessary bounded provenance. Prefer a threshold proof over exact birth date, document number or a reusable face identifier; use pairwise references to limit cross-service correlation;
- avoid retaining raw selfies/documents or their reusable hashes in application storage, logs, telemetry, queues or backups. Prefer direct provider capture under an explicit data-processing contract or suitable on-device processing; any transient application handling needs strict size/time/access limits;
- require provider purpose limits, deletion evidence and no secondary training, advertising or identity profiling. Not storing a photo locally does not mean no personal data was processed;
- retain deterministic empty/
mock_*provider behavior for development, marked with mock provenance that production authorization must reject.
Under GDPR, a photograph is not automatically Article 9 biometric processing; the processing purpose, including unique identification, matters. Other laws may classify facial data differently. Both the image and derived result need a reviewed processing basis and safeguards; “age estimation only” is not a blanket exemption. GDPR, recital 51 and Article 9.
Starters, examples and Academy
All generated application shapes should share the reviewed privacy defaults. Generate an operator-completed data inventory and configuration, notices that identify missing information, and accessible preference/rights routes where the selected starter processes personal data. Do not insert camera flows, cookie banners or a heavyweight provider into a database-free starter that has no corresponding need.
SaaS must distinguish billing records retained for a reviewed obligation from marketing data that can be removed. LMS/Academy must protect learner progress, guardian relationships, tutor history, submissions and any optional exam supervision separately. Age assurance does not authorize surveillance or prove exam misconduct. API-only projects need equivalent server-side enforcement.
Rullst/examples and the private Academy remain separately released consumers.
Use synthetic test subjects and publish only sanitized framework evidence.
Record application/framework versions and provider environments. A generated
mock flow is not deployment evidence for either production or staging.
Prioritized delivery and acceptance
| Phase | Delivery | Required evidence |
|---|---|---|
| P0.0 | Threat model, jurisdiction/profile format, data flow, package ADR and LMS migration design | Reviewed applicability assumptions, unknown/conflict behavior, model/provider lifecycle and first Academy/SaaS journeys. |
| P0.1 | Bounded privacy policy, consent/withdrawal, rights/retention contracts and explicit age/guardian evidence types | Deterministic tests for withdrawal, purpose changes, expired evidence, replay, tenant isolation and production mock rejection. |
| P0.2 — approved 13.0.0 foundation | First real privacy workflow and provider-neutral age gate, including an authenticated first-party declaration where the selected policy permits it | Durable state, session/tenant/action binding, effective rights-adapter execution, outage/cancellation handling and rejection of mocks or declarations used for stronger-policy actions. |
| Optional provider expansion | One reviewed external age adapter with an appropriate alternative | Native protocol and authorized sandbox evidence, capture/session binding, evaluated method strength, data lifecycle and appeal flow. The independent foundation cannot claim this integration before these gates pass. |
| P0.3 | SaaS starter and LMS example adoption and versioned examples/Academy migration | Browser/API negatives, no optional tracking before permission, real export/delete execution, backup-restore behavior and no image/identity leakage. |
| After foundation | More regional profiles, credential issuers and an optional local facial estimator | Per-profile review; model provenance/licensing, integrity-pinned weights, supported devices, threshold error rates, demographic fairness and attack evaluations. No bundled unvalidated “simple AI”. |
The release owner approved the independent foundation and optional-provider approach on 20 September 2026 UTC. Facial estimation remains an optional, evaluated expansion in the v13 roadmap; a local facial engine is follow-up work outside the committed 13.0.0 scope. Any facial release claims still require false-accept/false-reject results near each chosen threshold, relevant audience and device coverage, data lifecycle evidence, accessibility and independent review appropriate to the risk. One provider’s evaluation does not validate every adapter or jurisdiction.
The first privacy/age journey takes precedence over additional learning-game, monitoring and speculative integration features. Existing v12.1 maintenance and verification-efficiency work remain separately deliverable. See the SaaS maintenance triage for the payment work already required before enabling affected live operations.
The planned v13 Verus pilot starts with this crate’s method, threshold and evidence-decision contracts. Its proofs must follow the actual production functions and state their clock, issuer and replay-store assumptions. They supplement the acceptance evidence above; facial accuracy, guardianship, live provider behavior and legal applicability require their own review.
Transparent exam and parental supervision
Status: v13 candidate, first published in the 13.0.0-alpha.1 prerelease. The implemented contract and observation extension passed combined source/archive acceptance in PR #239. The package is included in the release inventory, pending final release acceptance. It supplies no verified guardianship, device-wide control or legal-compliance certification. Its maturity tier is Experimental.
Responsible use
rullst-supervision is designed for consented, transparent supervision:
online exams and assessments, and parental controls over a learning
application. Read this section before you integrate it.
- The person supervised knows and agrees. Collection starts only after the learner acknowledges the exact policy and notice versions and the selected collection categories. The session state stays visible, and the learner can pause or end collection. Re-enabling a removed category needs a new session and a new acknowledgement.
- Only typed observations. The store accepts the selected categories as typed events: page visibility, window focus, clipboard occurrences (never contents), fullscreen changes, capture status and optional camera-presence or audio-activity findings. Camera and audio findings come only from adapters your application supplies, for sessions whose acknowledged collection includes them. The crate captures no media itself and accepts no arbitrary payloads.
- No covert mode. There is no hidden or silent capture mode, and the project will not add one. A request for covert collection is out of scope.
- Not for general surveillance. The crate is not designed for security cameras, CCTV, workplace monitoring or watching people who have not been told. Observations are incomplete and forgeable; they never prove misconduct, identity or attendance, and they must not trigger automatic penalties.
- You are responsible for the law. Operators must comply with the law where they and the people supervised are, including data-protection and children’s-privacy rules. Take particular care with minors: confirm who may consent for them and verify guardian relationships independently. The crate provides no legal-compliance certification.
Package and integration boundary
The separate crate has optional exam, parental, sqlite and analysis features. The
SQLite adapter selects both domain modules; neither the crate nor storage becomes
a Core/default dependency. Its first backend uses a concrete adapter generic
over a trusted server clock, with real SQLite and deterministic clocks in tests.
Applications integrate it directly. v13 removed the generated full-LMS consumer
(make:supervision) together with the complete Academy scaffold it targeted.
The host authenticates the actor and checks current school membership, course access and administrative authority. Store context constructors validate opaque identifiers; they do not authenticate a browser or prove a family relationship. Host membership checks and supervision state are separate transaction boundaries. They must run on every request; do not reuse a browser’s tenant/role claim.
Scoped authority
A trusted operator can install an expiring grant for one tenant, learner,
resource, delegate and action after independently verifying the relationship.
Provisioning includes a bounded opaque evidence reference; no identity documents
or raw verification evidence are stored. ExamReview and ParentalManage are
distinct. A parental manager cannot read exam observations through that grant.
Generic teacher roles, age results, matching email names and checkboxes confer
no such authority. No public self-provisioning route is planned.
Every delegate operation checks the current grant in its SQLite transaction. Completed grant revocation blocks subsequent operations, including reads. It cannot recall an already committed response. Revoking a parental manager does not remove the learner’s content restrictions; only the operator can remove management explicitly. A persistent global revision counter prevents stale forms from matching a recreated grant or policy after bounded cleanup.
Visible exam sessions
The learner explicitly starts collection for a server-selected resource and acknowledges exact policy/notice versions and the selected collection categories. Session IDs are random. The learner can pause, resume with fresh acknowledgement or end collection. End is terminal; every transition uses the displayed revision, and lifetime is at most eight hours. Starting also compares the last retained session revision atomically; a form from before a pause/end cannot silently start a new session. Application forms should expire well before the minimum session retention, as the removed generated forms did. Session access is tenant/subject bound; existing course/assessment access must still be checked by the application.
Visibility remains the default. Explicit selection also supports browser focus, clipboard occurrence and fullscreen events, capture status and optional adapter findings. Each observation binds a session, its current revision and the exact next client sequence. Unknown, repeated, out-of-order, paused, ended, expired and over-budget submissions are rejected. Receipt time comes from the server; event rate and stored counts are bounded. Events do not extend lifetime. They are incomplete, forgeable reports and never establish cheating, attendance, attention, identity or time spent learning. No event changes grades, answers, enrollment or penalties.
The crate captures no camera, microphone or screen media, and collects no key logging, clipboard content, browsing history, location, fingerprint or arbitrary payload; camera and audio findings come only from the host’s adapters. The consumer must show active/paused/ended state and stop sending after pause/end. No-JavaScript controls must still work without claiming visibility observations.
Observation extension candidate
The September 20 extension keeps the implementation in rullst-supervision.
There is no separate rullst-proctoring crate. It adds exact collection selection,
typed browser/capture observations and bounded camera-presence/audio-activity
adapter orchestration. The integration guide covers
the APIs, source attribution, cancellation, schema transition and test boundaries.
A browser consumer observes only selected events on its own page. It cannot enumerate every open window/application or recognize outside answers. The host owns media capture permission, a local or remote model, accessibility alternatives and human review; the framework includes no detector or automatic penalty.
Parental application restrictions
An operator enrolls a learner and a server-selected access-policy resource into parental management. Enrolled learners are denied while no valid policy exists. An explicitly authorized manager can set a bounded course allowlist and one absolute UTC access window, using the current revision. Missing/expired policy, clock failure and storage failure deny protected operations. Unmanaged learners retain the existing learning authorization. These restrictions cannot grant enrollment, unpublished lessons or assessment permissions.
The host integration must enforce policy on the original lesson-play and progress paths. A new dashboard alone is insufficient. Existing independently authorized cross-subject administrator corrections remain administrative work, not learner access. They retain role, actor/subject membership and original course checks; they confer no reviewer or parental-manager authority. The learner can inspect the applicable window/policy. Already delivered content cannot be recalled. There is no operating-system control, recurring timezone schedule or daily screen-time accounting in this increment.
SQLite, clocks and retention
Use one private local database with explicit exclusive initialization, verified
schema/configuration, WAL/full synchronization, bounded pool/lock waits and
parameterized SQL. open never creates or repairs missing state. The deployment
epoch must match the independently retained configuration. A trusted-directory
assumption is required; the adapter is not a sandbox for hostile filesystem
administrators or network filesystems.
BEGIN IMMEDIATE serializes authority reads, state changes and clock/revision
advancement. Check time after waits and before returning an authorized result.
Clock rollback, malformed records, capacity exhaustion and uncertain commits
fail closed. A failed or cancelled mutation can have an uncertain outcome;
reload state before retrying, and never infer success from a timeout.
Event retention is explicit, from one hour to seven days. Reads exclude expired
events immediately; bounded operator cleanup removes eligible rows. Session
metadata has bounded retention after its maximum lifetime. Grants and managed
learners have separate hard capacities; active management is never evicted.
Capacities are store-wide. An ended session keeps its slot until its retention
(start, policy lifetime and event retention) ends, so one learner (tenant and
subject) may retain at most 64 sessions, or the store-wide limit when lower;
Limits::subject_sessions selects another bound that every opener must share.
Events stay until their own retention ends, even after the session ends, so one
learner may also hold at most 4096 unexpired events across all sessions, or the
store-wide event limit when lower; a further observation returns Capacity.
Limits::subject_events selects another event bound that every opener must share.
When a store-wide session or event limit is reached, admission first removes a
bounded batch of rows already past retention in any tenant, so logically
deleted state never blocks new work. Hosts should still rate-limit starts.
SQL deletion does not erase copies in WAL, free pages or backups. Operators own
file permissions, encryption, keys, backups and retention beyond these records.
Restoring a stale database with its old epoch can restore revoked authority:
quiesce the feature and re-establish authority/policies with a new epoch before
resuming. No automatic rollback or failover claim is made.
Acceptance before package admission
- Real database reopen and new-process operation, two independent pools, exact revision races, expiry during lock waits, cancellation, corruption, capacity, clock rollback and bounded retention/purge.
- Cross-school/learner/delegate negatives; expired/revoked grants; no role-only authority, no parental-to-exam privilege escalation and no stale-form replay.
- Real generated LMS authentication, CSRF, operator provisioning, session transitions and original learning-route enforcement; existing access denial remains effective for unmanaged and managed learners.
- Actual browser exercise with visible controls, keyboard/no-JS behavior, minimal event payloads and no capture or background reports after stop.
- Full workspace, strict Clippy, formatting, feature/panic boundaries and an installed-archive consumer before registry inclusion. Phase completion means the named behavior and tests exist; no mock-only package admission.
The generated LMS consumer supplied the application and browser evidence above before v13 removed it. Applications that integrate the crate directly need their own equivalent acceptance.
Application integration
v13 no longer generates a supervision consumer: make:supervision targeted the
complete Academy LMS, which v13 removed (the smaller LMS is now the
examples/lms application, without supervision). Integrate the crate directly. The host application authenticates the actor, checks membership
and authority, provisions operators out of band and wires the SSR controls and
retention workers to the contracts above. Existing stores are never upgraded or
enabled by default. The removed generator remains in the git history as a
reference implementation.
Building an exam observation platform
v13 candidate, first published in the 13.0.0-alpha.1 prerelease.
rullst-supervision supplies scoped sessions,
selected collection categories, bounded observation storage and optional adapter
orchestration. It does not supply an entire assessment product or camera/audio
model. A developer supplies the exam UI, authenticated membership and enrollment,
media capture, model, reviewer workflow and accessibility alternatives.
Choose and acknowledge exact categories
exam::Collection selects from Capability: visibility, window focus, clipboard
occurrence, fullscreen, camera/microphone/screen-share status, camera presence and
audio activity. Collection::visibility_only() is the default. Use
ExamPolicy::with_collection and Acknowledgement::for_collection with the same
set and policy/notice versions. Show that set before starting; an older or broader
acknowledgement cannot start/resume a differently configured session.
The stored Session exposes both the initial and current collection. A learner
can pause/end, or the host can call restrict_collection with the learner’s
current authenticated context and revision to remove categories. This changes
the revision and fences pending results. Re-enabling a removed category requires
a new session with a new acknowledgement. Existing retained observations remain
available to the learner and separately authorized reviewers until expiry.
Browser permission and the legal basis selected by the application are separate
from this acknowledgement; selecting a capability proves neither.
Browser observations
v13 no longer generates a consumer (make:supervision targeted the removed
complete Academy LMS). An application that integrates the crate selects the
observation categories explicitly; visibility alone is the conservative default.
The notice and start/resume acknowledgement must show and bind the selected
categories. The crate includes no camera/microphone permission prompt, analysis
endpoint or background media upload, and ordinary session controls should work
without JavaScript.
| Observation | Meaning and limit |
|---|---|
| Page visible/hidden | This page’s visibility changed; no inventory of other tabs. |
| Window focused/blurred | This window gained/lost focus; does not identify another window. |
| Copy/cut/paste attempt | An occurrence on this page; never clipboard contents or selected text. |
| Fullscreen entered/exited | Fullscreen changed; no forced fullscreen or integrity guarantee. |
| Capture started/stopped/denied/unavailable | Host-supplied client status for camera, microphone or screen share; no independent verification of capture. |
The browser collector is application code; the crate ships none. The removed generator’s collector ignored synthetic DOM events, but a user can modify browser code or forge network reports: this is not authenticity or tamper protection. It serialized one request at a time, waiting at least 1.1 seconds between requests, with sixteen waiting occurrences at most. Queue overflow, network errors, non-204 responses, page exit, form/session expiry or a session-control submission stopped collection and cleared the queue. It did not automatically retry ambiguous writes. A pause/end submission stopped that page before the server confirmed the transition; other tabs are fenced by the committed server revision. Receipt time is server reception time, not necessarily occurrence time.
Custom applications use ObservationRequest::new and record_browser or
record_capture. The request binds authenticated context, tenant/learner/resource,
session ID, current revision and the exact next sequence. Never accept Context
or its tenant/actor directly from an untrusted payload. The host refreshes access
before each operation. The store separately checks state, capability, revision,
sequence, rate, capacity and expiry transactionally. A page must not be able to
post an adapter finding through a browser-event route.
Optional camera/audio adapters
Enable analysis plus sqlite to call SqliteSupervision::analyze. The
analysis feature alone exposes database-free, statically dispatched contracts:
Analyzer: a bounded implementation returning a typedFindingand a fixedAnalyzerDescriptorwith opaque ID, version, kind and simulation attribution.AnalysisAuthorization: application authorization refreshed before analysis and before recording, including current membership, entitlement and permission.MediaSample: borrowed JPEG/PNG bytes up to 1 MiB, or up to five seconds of mono 16 kHz signed 16-bit little-endian PCM (160,000 bytes). Signature/length checks do not decode, establish media validity or bind it to a learner.AnalysisOptions: a 1–15 second orchestration timeout. Simulated adapters are rejected unlessallow_simulated_for_testing()is explicitly selected.
A local model is allowed; no paid provider is required. Implementations must bound image dimensions/decoding, CPU, memory, network, retries and output, and honor cancellation. A valid small compressed image can still require excessive decoding resources. Run blocking or untrusted inference in an isolated worker with enforceable limits; a Tokio timeout does not stop synchronous CPU work or an external process. Do not log samples or credentials or include them in errors. The framework stores only typed findings and source metadata, with no raw media. The caller owns capture and transient sample lifetime and must stop/release its media tracks when permission is revoked, paused or ended.
Camera results are person detected, no person detected or inconclusive. Audio
results are speech detected, no speech detected or inconclusive. A detector
failure returns AnalysisUnavailable; it must not become a negative observation.
No result identifies a person, speaker, emotion, intent, supplied answer or
misconduct. Missing reports never prove absence of an event. Source metadata
attributes the host’s selected adapter; it is not a cryptographic attestation.
The SQLite orchestration checks authorization, then reserves one pending lease per session across all processes before invoking the adapter. It releases the SQLite transaction while analysis runs. Recording checks authorization again and rechecks current session/revision/capability/sequence and the exact unexpired lease. Pausing, ending, narrowing collection or expiring the lease fences the result. Browser and analysis submissions share one sequence; the application must coordinate producers, reload on conflicts and avoid replaying stale work. A host membership check and the supervision database are separate transaction boundaries, so the host owns atomic integration if its threat model requires it.
Cancellation/failure can leave a lease until its deadline (at most fifteen
seconds or session expiry). A timeout returns UncertainCommit: reload session
and observations before retrying, because a write might have committed. There
is no silent permission renewal, automatic scoring or eviction of active state.
The timeout requires a Tokio runtime with its timer driver enabled.
An executable offline example implements the traits with an explicitly simulated inconclusive result and a disposable local store:
cargo run -p rullst-supervision --example observation_adapter --features sqlite,analysis
It uses no camera, microphone, network or third-party account. Replace its fixed fixture authorization with real current application authorization. A simulated pass does not establish model accuracy or provider interoperability.
Storage transition and review
This extension uses SQLite schema v2. Opening v1 refuses it without migrating its schema or data. Preserve old records under the existing retention policy; quiesce the old preview and prepare a separately named private v2 store, with a new independently retained deployment epoch and independently re-established authority/restrictions. A fresh store has no parental enrollment, so provision required restrictions before allowing protected learning. Do not delete old state or change only its epoch to bypass refusal. There is no live upgrade tool yet.
observations returns at most 100 retained entries per page to the learner or a
currently authorized exam reviewer. events preserves the legacy visibility-only
view. Parental-management authority never grants observation review. The existing
rate, capacity, grant, retention, private-file, clock and rollback boundaries in
the supervision contract apply. Deleting records does not erase
WAL, free pages or backups.
Acceptance evidence
Tests exercise real SQLite persistence, exact acknowledgement, disabled categories, collection narrowing and stale revisions, separate source paths, all supported browser/capture kinds, legacy visibility, v1 rejection, simulated adapters, wrong-kind/failure handling, authorization revocation during analysis, shared leases, timeout and no retained sample contents. Before v13 removed the generated LMS consumer, deterministic JavaScript tests exercised its collector’s queue, sequence, selected events and stop/failure behavior, and a generated LMS/Chromium journey validated HTTP/security/forms and browser behavior.
Before that removal, local validation passed the five generator/process/composition contracts with Chromium enabled, the generated LMS’s fourteen original library tests and production Clippy, 378 CLI unit tests, all-target strict Clippy for the CLI and crate, seven isolated feature configurations, the database-free analysis contracts and the executable offline example. The final focused sample caught all nine selected authorization/restriction mutations, including a regression for current-revision attempts against paused and ended sessions. This is a bounded sample, not a whole-crate mutation score.
The extension passed hosted workspace checks in PR #226. Its initially skipped archive gate subsequently passed in exact-commit validation. The delivery plan records that retrospective repair. The final release campaign remains required; no live provider/model test is claimed.
Browser boundaries follow Page Visibility, focus events, copy events, camera/microphone permission and screen capture.
Private course videos with Bunny Stream
rullst-media is an optional v13 candidate, first published in the
13.0.0-alpha.1 prerelease. It implements a bounded
private-video lifecycle: authorized creation and resumable upload, processing
refresh, explicit publication, current-entitlement playback, withdrawal, metadata
updates, deletion and durable reconciliation. Its standalone package is in the
v13 release inventory, pending final release acceptance; it is not part of the framework facade. Existing starters are not
changed.
The crate integration guide describes installation
(rullst-media = { version = "=13.0.0-alpha.4", features = ["bunny", "sqlite"] }),
separate provider keys, required embed/CDN/direct-file
protection, HTTP security composition, browser uploads, captions/transcripts,
typed failure handling, SQLite retention and backup responsibilities.
Run cargo run -p rullst-media --example private_course_video --all-features
to exercise the executable offline journey. Empty or mock_* credentials never
become live playback authority; production store configuration rejects them.
Applications supply authenticated tenant/course membership and current entitlement.
Automated acceptance uses actual local HTTP, SQLite and Chromium with controlled media. It checks signatures, upload bytes and failure recovery, CSRF and tenant denial, media decoding, captions/keyboard interaction, withdrawal and deletion. Real Bunny account, transcoding, CDN and iframe interoperability remain unvalidated under the owner’s no-live-provider-testing instruction. Signed URLs do not imply DRM, immediate revocation of existing capabilities or prevention of all copying.
See the managed-video acceptance roadmap and the specification. Release acceptance remains separate from implementation and local test success.
S3-compatible object storage (S3, R2, MinIO)
Status: Experimental; validated against the offline mock only; provider
interoperability not yet validated. The opt-in s3 feature adds
rullst_media::s3::S3Storage, a second VideoProvider beside Bunny Stream for
AWS S3, Cloudflare R2 and MinIO (custom endpoint). It stores and serves the
uploaded original. There is no transcoding, HLS or renditions: browsers play
the file that was uploaded, so upload a format they can decode (for example
H.264/AAC MP4).
| Step | Mechanism |
|---|---|
| Create | The adapter derives the video id from the local creation marker and writes a small private metadata.json beside the object. |
| Upload | MediaService::upload_declared checks the host’s Authorization, then signs a presigned PUT for {prefix}{video}/original. The signature binds the declared Content-Type and exact Content-Length; types outside with_content_types and sizes above with_max_object_bytes are refused before signing. |
| Confirm | refresh or publish sends HEAD; a non-empty object within with_max_object_bytes whose type is accepted becomes Ready, anything else Failed (a new grant may then replace it). |
| Playback | playback(..., PlaybackKind::Original) returns a presigned GET after the usual entitlement and publication checks. Range requests go directly to the bucket. |
| Delete | delete removes the original and its metadata object. |
Object keys are always generated by the server: an optional validated prefix
plus the adapter’s UUID-shaped id; no request value becomes a path. Upload and
playback grants expire after at most 900 seconds (s3::MAX_GRANT_SECONDS) and
never after the host’s permission. Signed URLs live only in the private
UploadGrant signature and PlaybackGrant::expose_url; Debug output and
MediaError never contain them, and signing runs with tracing disabled. Never
log the grant JSON you send to the browser.
use rullst_media::{LibraryId, UploadDeclaration, s3::{S3Config, S3Credentials, S3Storage}};
// AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY (+ optional AWS_SESSION_TOKEN).
// Unset keys select the offline mock, which StoreConfig::production rejects.
let credentials = S3Credentials::from_env()?;
// Cloudflare R2: https://<account>.r2.cloudflarestorage.com, region `auto`.
let config = S3Config::r2(LibraryId::new(1)?, "<account-id>", "course-videos", credentials)?
.with_key_prefix("lessons/")?
.with_content_types(["video/mp4", "video/webm"])?
.with_max_object_bytes(2 * 1024 * 1024 * 1024)?;
// AWS S3: S3Config::aws(library, "eu-west-1", "course-videos", credentials)
// MinIO: S3Config::custom(library, "https://minio.example.internal:9000", "us-east-1", "course-videos", credentials)
// (path style; HTTPS required except for a literal loopback address)
let provider = S3Storage::new(config)?;
// In the CSRF-protected upload-grant route, after validating the browser's file:
let declared = UploadDeclaration::new(file_type, file_size)?;
let grant = media.upload_declared(&policy, &actor, &scope, &lesson, 600, &declared).await?;
The browser then sends exactly one request:
// grant.protocol === "PresignedPut"
await fetch(`${grant.endpoint}?${grant.signature}`, {
method: 'PUT',
headers: { 'Content-Type': grant.content_type },
body: file, // its size must equal grant.content_length
});
Bucket CORS must allow the application origin to PUT with a Content-Type
header and to GET/HEAD with Range. Keep the bucket private: no public
access, no public r2.dev URL and no custom-domain public access. Credentials
need GetObject, PutObject and DeleteObject on the prefix and ListBucket
(so that HEAD on a missing object returns 404 instead of 403). An AWS S3 CORS
configuration (R2 accepts the same rules as JSON in its dashboard or API):
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT", "GET", "HEAD"],
"AllowedHeaders": ["content-type", "range"],
"ExposeHeaders": ["ETag", "Content-Length", "Content-Range", "Accept-Ranges"],
"MaxAgeSeconds": 600
}
]
Limits: one PUT uploads at most 5 GiB and is not resumable; there is no
multipart upload, malware scanning, transcoding or thumbnailing. Whether a
provider enforces the signed Content-Length and Content-Type is part of the
unvalidated interoperability; the HEAD check after upload is the enforced
bound. A presigned URL is a bearer capability until it expires, even after
withdrawal or deletion. Provider event notifications are not consumed;
confirmation is explicit through refresh or publish.
Rullst Specification 📄
“The Single Source of Truth (SST) for Framework Architecture & Conventions”
This document is the Single Source of Truth (SST) for the Rullst Framework. It specifies the exact conventions, API structures, naming rules, directory standards, and subsystem maturity lifecycles across all monorepo crates.
Important
AI & Human Alignment Directive: Whenever updating, refactoring, or generating code/documentation for Rullst, always refer to this specification as the baseline. Every capability in the framework is strictly tagged with its implementation lifecycle status:
- 🟢
[Implemented / Bounded]: A defined implementation exists with automated tests for the stated scope. This is not a deployment, provider-homologation, or certification claim.- 🟠
[Partial]: Useful foundations exist, but a named interoperability, architecture, or conformance boundary is still incomplete.- 🟡
[Offline Test Mock]: Deterministic offline sandbox fixtures for local development and offline CI without external API dependencies.- 🔵
[Roadmap / Under Development]: Planned architecture, traits or domain models; consult each entry for what exists and what remains unimplemented.
📂 1. Directory Structure Conventions
A standard Rullst application scaffold strictly adheres to this folder hierarchy:
my-app/
├── src/
│ ├── controllers/ # Route controllers (async request handlers)
│ │ └── mod.rs
│ ├── models/ # Active Record & Repository Models (rullst-orm entities)
│ │ └── mod.rs
│ ├── pages/ # Shared HTML views, templates, and layouts
│ │ └── mod.rs
│ ├── middlewares/ # Custom application middleware layers
│ │ └── mod.rs
│ ├── migrations/ # Schema migrations (database-backed projects)
│ │ └── mod.rs
│ └── main.rs # Application entrypoint, server bootstrap & central routing
├── static/ # Same-origin static assets (HTML starters)
├── Cargo.toml # Project cargo dependencies
└── Rullst.toml # Framework configuration (database, environment, secrets)
The SaaS starter creates every folder above. The Blank and API starters begin
with src/main.rs and, for a database profile, src/migrations/; the make:*
generators create the other folders and their mod.rs on first use. A
--no-database Blank project has no Rullst.toml. Every starter also writes
.env.example, AGENTS.md, .llms.txt and .rullst/context-map.json
(section 11.1).
🛠️ 2. Naming Conventions
To guarantee consistency, both humans and AI coders must adhere to the following naming normalization rules:
- File Names: Standard Rust
snake_case(e.g.users_controller.rs,post_model.rs,billing_service.rs). - Struct / Model / Trait Names: Standard
PascalCase(e.g.UsersController,PostModel,PaymentProvider). - URL Paths: Lowercase kebab-case (e.g.
/users,/user-profiles,/billing/webhooks). - Database Identifiers: Snake case (e.g.
user_id,created_at,billing_accounts).
Optional browser graphics example (v13 candidate)
examples/webgpu is a non-publishable teaching application served by Core’s
production security baseline. It demonstrates two-source wave interference with
browser-native WebGPU and a lower-resolution Canvas fallback. No GPU dependency
or default feature is added to the framework, and no GPU work runs on the server.
The example starts paused, bounds resolution and frame submission, handles GPU
startup failure/device loss, and explicitly disposes resources on removal or
navigation. It uses same-origin assets without a bundler, external service or
capture permission. Client results are illustrative, never authorization or
grading evidence. Automated Chromium/software-GPU checks are bounded example
acceptance, not physical-device, browser-universal or performance evidence.
⚡ 3. Framework Crates & Capability Matrix
The v13 maintenance scope decision governs future investment and proposed extension boundaries. It does not describe an already completed package split or change current APIs, defaults, support obligations or publication inventory. The implemented capability matrix below remains the source of truth until individual migrations are reviewed and validated.
Each crate also has a v13 maturity tier: Core (rullst,
rullst-core, rullst-macros, rullst-orm, rullst-orm-macros,
rullst-security, cargo-rullst), Extension (rullst-auth,
rullst-connect, rullst-mail, rullst-capital, rullst-ai, rullst-nexus,
rullst-studio, rullst-messaging) or Experimental (rullst-iot,
rullst-privacy, rullst-supervision, rullst-media, rullst-labs). The tier
sets the 13.x stability and validation promise; the matrix below records what
each crate implements.
| Crate | Responsibilities | Status & Capabilities |
|---|---|---|
rullst-core | Kernel HTTP runtime, routes!, Server bootstrap, HTML engine, async task queues, WebSockets, circular telemetry buffers, storage facade, and the default baseline CSRF/WAF/header/PII stack. | 🟢 [Implemented / Bounded]: Routing, server lifecycle, html! engine, graceful shutdown, backpressure guard, queues, and local storage with path-traversal protection. The process-local RateLimiter keys IPv6 peers per /64 and tracks at most 100,000 buckets, dropping refilled ones and evicting the least recently used beyond that cap. ApplicationLifecycle adds an opt-in process-local monotonic startup/ready/draining/stopped state, at most 32 immutable component readiness bits, secret-minimized /ready, fail-closed admission and a bounded drain wait. Server marks ready after binding, begins drain before Axum’s graceful wait, and accepts an explicit supervisor shutdown future; deterministic tests cover startup failure, in-flight completion, rejection after drain and lock poisoning. It does not run dependency checks, coordinate replicas/load balancers or authorize domain requests. SQLite and Redis persist dispatch_at timestamps for at most 366 days and never claim them early; Redis promotion uses server time and a digest-pinned live CI/release contract. The Redis cache and queue drivers share one lazily opened multiplexed connection per driver and reconnect after a connection-level failure instead of opening a connection per operation. Execution starts on the first later worker poll and is at-least-once. The worker never cancels an in-flight claim; graceful shutdown requeues a job claimed after the request. Timeout and shutdown record a handler’s own result when it finishes before cancellation takes effect, so only a cancelled handler is failed as timed out or requeued. Stalled-lease recovery runs at worker start and then every min(stalled_after, 60 s) over every processing lease of the shared SQLite table or Redis namespace, and in v13 workers claim with QueueDriver::pop_with_lease so SQLite and Redis store each claim’s own stalled_after lease and recovery honours it; only claims without a lease need every sharing worker’s stalled_after to exceed the longest job_timeout among them; SQLite and Redis count stalled leases per job and fail the job at its fifth stalled lease instead of requeuing it forever (retry_failed_job restarts the count; the v13 try_with_max_stalled_leases driver builders accept 1–1,000). Worker transitions are fenced by the claimed attempt number in SQLite and Redis, so a stale lease cannot complete, fail or requeue a recovered and re-claimed job; custom drivers inherit unfenced defaults, and SQLite and Redis retry_failed_job keep the counter so the fence holds across retries. A worker without a handler for a claimed job’s name hands it back with a five-second delay in SQLite and Redis instead of failing it, until the claim’s 720th attempt fails it (section 4.3); custom drivers without requeue_attempt_after still fail it. Redis retains at most 10,000 failed jobs and 10,000 dead letters by default (1–100,000 via try_with_failure_retention), evicting the oldest atomically, and implements bounded list_all_jobs (at most 1,000 rows), retry_failed_job and purge_failed_jobs. The v13 QueueDriver::list_job_previews returns those records with each payload and error cut to a caller-chosen byte budget on a UTF-8 boundary; SQLite cuts them in SQL and Redis in one atomic Lua script, while custom drivers inherit a projection of list_all_jobs. Worker and scheduler handles buffer at most 256 undrained errors; overflow is dropped, counted by dropped_errors() and logged as a tracing warning. Server drains the scheduler handle it owns, logging each task failure, and returns a scheduler error at shutdown only for a failed scheduler loop. Custom drivers fail closed for future scheduling until implemented. TenantStorage, TenantCache, TenantRealtime and TenantPresence bind those facades to a validated TenantContext, apply immutable tenant namespaces and prove same-name local non-interference; the realtime wrappers also bound channel/event/identity names and payload size. BroadcastManager retains a channel only while it has a subscriber or an outstanding handle (publishing never creates one) and PresenceTracker counts connections per user and drops empty rooms, so neither registry grows with abandoned names. Memory and Redis caches expose an opt-in, at-most-200-entry metadata snapshot containing logical key, UTF-8 value length and remaining TTL but never the value; custom drivers fail explicitly unless they implement that method. Remote bucket policy, distributed transport/liveness, cache operator authorization and application room authorization remain deployment/application work. In a supervised debug Development process (RULLST_DEV_GENERATION set by cargo rullst dev/dash), Server also serves GET /_rullst/dev-telemetry to loopback peers with a loopback Host: bounded request counters and the newest 64 requests (method, query-free path, status, duration), outermost rullst.orm.query operation counts with the newest 16 slow operations, and the pending count of a queue passed to the v13 Server::with_dev_queue, plus the newest 16 possible-N+1 findings (an ORM label fingerprint that one request, identified by its matched route, repeated at least three times, using the shared rullst_core::query_patterns rule that Studio’s threshold also uses); bodies, headers, query strings, SQL, bindings and error text are never recorded, and release builds, Staging and Production never mount it. For deployed applications, the opt-in 13.0.0-alpha.3 Server::with_monitor serves the authenticated read-only GET /_rullst/monitor/v1 document instead (section 10.1.1).🟢 [Implemented / Bounded]: The in-memory upload admission contract enforces a hard size/allowlist boundary, canonical tenant/name, recognized signature versus MIME/extension, active-text denial, randomized tenant quarantine keys, SHA-256 binding and fail-closed scanner release. It is not a deep parser, remote persistence or a production malware engine. Since 13.0.0-alpha.2 uploads::Multipart reads multipart/form-data under an explicit MultipartPolicy, streaming each file to memory or a private temporary file and recognizing content with the same signatures (section 4.1, “Multipart Form Uploads”).🟢 [Implemented / Bounded]: Validated environment precedence is RULLST_ENV, legacy APP_ENV (from the process, then from the .env that Server reads, which RullstConfig::environment also honours once a Server has started), then [app].env; invalid values fail instead of silently enabling development.🟢 [Implemented / Bounded]: apply_security_baseline and Server compose configured CSP nonce headers, exact-origin CORS with explicit credential opt-in, bounded WAF, double-submit CSRF and optional PII masking in one tested order, with the per-application config installed outside every middleware. Browser/proxy/TLS deployment evidence and application-owned session/auth/tenant/authorization remain separate. A fail-closed typed Academy boundary-assessment contract records those application observations without certifying them, and the extended rullst-security stack is still composed explicitly. The opt-in v13 Server::trusted_proxies stage resolves clients behind listed reverse-proxy networks outside that baseline (section 4.1).🟢 [Implemented / Bounded]: client_contract exposes the portable rullst.client v1 typed JSON envelope, positive version negotiation, bounded correlation/idempotency/failure tokens, server-authored time and a fail-closed 2 MiB codec on native and Wasm. It deliberately contains no role, tenant or authorization assertion; durable replay and domain policy remain server/application work.🟢 [Implemented / Bounded, v13]: Optional storage-s3 private S3/R2 operations passed hosted source/package admission in PR #236; final release admission and actual owner-account interoperability remain outstanding. See section 4.4. |
rullst-orm | Active Record & Repository patterns, parameterized SQLx connection pool (PostgreSQL, MySQL/MariaDB, SQLite), typed Turso/libSQL primary profile, schema migrations, AES-256-GCM privacy, Scout search, typed pgvector/Qdrant queries, Redis native structures, and optional capability-oriented persistence adapters. | 🟢 [Implemented / Bounded]: Relational CRUD, eager loading, type-safe queries, migration runner, versioned field encryption, and connection-pool resilience for supported SQLx drivers/features. PostgreSQL, MySQL, MariaDB and SQLite have distinct executable matrix contracts, while MariaDB intentionally shares SQLx’s MySQL protocol/backend.🟢 [Implemented / Bounded]: #[derive(Orm)] #[orm(backend = "turso")] supplies typed CRUD, equality filters, ordering, pagination/counts and generated/app-assigned keys through a process-wide TursoOrm. Its migrations are ordered, checksummed, drift-detecting and reversible. The blank/API CLI profile generates, compiles, migrates, reports status and rolls back locally, while the same typed contract passes against the official remote libSQL server. Unsupported SQLx-specific model behaviors fail during macro expansion rather than being ignored. Other SQLx-specific blueprints, ORM relations/hooks, schema auto-diff, seed generation and transparent embedded-replica synchronization are not part of this bounded Turso profile.🟢 [Implemented / Bounded]: The optional persistence boundary supplies portable document CRUD for MongoDB and SurrealDB, parameterized OLAP queries through in-process DuckDB, explicit parameterized Turso/libSQL SQL/transactions, and bounded read-only ISO GQL through SurrealDB. These capability APIs do not claim shared semantics or cross-store transactions. External adapters select deterministic offline behavior for empty or mock_* credentials where documented; SurrealDB uses its HTTP protocol rather than embedding the BSL-licensed SDK.🟢 [Implemented / Feature-gated]: scout-http provides bounded Meilisearch, Elasticsearch and Algolia indexing/search adapters plus deterministic mocks. Meilisearch has a digest-pinned live lifecycle; Elasticsearch/Algolia have protocol fixtures, not hosted-provider certification. Generated projections are process-local post-commit effects unless the application explicitly composes the transactional outbox.🟢 [Implemented / Feature-gated]: pgvector with strict-postgres supplies typed SQL vector helpers. qdrant supplies a separate bounded dense-vector collection/upsert/delete/cosine-query contract, while redis supplies namespaced Hash, Set and Sorted Set operations. All three have digest-pinned live lifecycles; RAG orchestration, authorization, production ANN tuning and Redis cluster/failover remain application/deployment boundaries.🟢 [Implemented / Benchmark Evidence]: A lockfile-pinned Criterion target compares five equivalent typed-SQLite shapes through one Rullst, Diesel and SeaORM connection under the same schema, seed and SQLite policy. It is per-run evidence, not a superiority, negligible-overhead, networked-database or full-application claim. |
rullst-auth | Argon2id password hashing, encrypted cookie sessions (AES-256-GCM), opt-in application JWTs, Passkey ceremony foundations, RBAC context guards. | 🟢 [Implemented / Bounded]: Non-blocking spawn_blocking Argon2id hashing, versioned expiring AES-256-GCM sessions with opt-in per-user session-epoch revocation, fail-closed RequireRoleLayer, compile-validated #[rullst::require_role], named Policy<User, Resource> decisions, and a feature-gated application JWT policy with required versioned claims, bounded TTL/scopes, strong HS256 keys, kid rotation and revocation contracts that reject process-local state in production mode.🟢 [Implemented / Feature-gated]: sqlite supplies bounded shared local auth state. SqliteJwtRevocationStore persists JTI expiry and monotonic subject session versions through serialized transactions, stored quota/configuration and async verification. SqlitePasskeyStore persists validated public credentials, bounded device inventory/rename/revocation and optimistic signature-counter CAS; executable restart, replay, quota, corruption/configuration and two-instance contention evidence covers both stores. Authentication, role persistence, resource/tenant/device ownership, trusted file permissions/encryption, backup and multi-host replication remain application/deployment boundaries.🟠 [Partial]: Passkey registration/assertion validates the documented ES256/none-attestation scope; the compatible PasskeyAuth challenge state remains process-local. The optional passkey-postgres v13 candidate adds a separate shared manager with bound single-use PostgreSQL ceremonies; independent-process/database-restart and Chromium virtual-authenticator contracts pass, and PR #223 passed hosted workspace/archive source acceptance. Final release admission remains separate. The v13 opaque-session inventory/logout increment extends the recovery store with process/database evidence and passed hosted source/package admission in PR #236. Final release admission remains separate. Normative WebAuthn conformance or adoption of an audited full server library, refresh tokens and complete recovery/session UX remain required before a general stable claim. |
rullst-security | Explicit extended defense-in-depth layers: bounded RASP, authenticated Vault, Login Jail, Secure Headers, rate limiting, DLP and security telemetry. | 🟢 [Implemented / Bounded]: AES-256-GCM envelopes with rotation/AAD, bounded URI/header/body RASP heuristics, local abuse controls, CSWSH origin guard, OS-random TOTP with SVG enrollment QR, strict JSON transport inspection plus an explicitly mounted reusable JSON Schema 2020-12/OpenAPI 3.1-component policy, explicit log redaction, file-backed SRI hashes, and a versioned/bounded LiveSecurityEvent v1 dashboard envelope. DurableSiemSpool preserves the compatible unsigned local format, while AuthenticatedSiemSpool offers an explicit HMAC-SHA256-chained format with named active/historical keys, zeroized key material, sequence/predecessor validation and byte/record quotas. Restart, forgery, wrong/missing keys, reordering, interior deletion, quota, symlink and external-length-change paths fail closed. Whole valid-tail rollback requires a separately trusted checkpoint, and the caller owns directory/key trust, permissions, retention and exclusive-writer operation. Schema construction caps bytes/nodes/depth, accepts only local references, disables network/filesystem resolution and uses linear-time regexes; auth/ownership/domain rules and query/header/form validation remain application contracts. A deterministic Sentinel classifies three caller-supplied aggregate patterns and can issue HMAC-authenticated, subject-bound, expiring, one-shot process-local proof-of-work challenges; it is not AI attribution, automatic blocking or distributed replay protection. The CLI emits bounded fail-closed evidence and a CycloneDX 1.5 Cargo SBOM; it does not certify the application.🟢 [Implemented / Feature-gated]: redis-rate-limit provides namespaced atomic Redis fixed-window counters over one lazily opened multiplexed connection per limiter (shared by its clones and reopened after a connection-level failure), hashes client keys and exposes an explicit process-local offline mode that production can reject with require_distributed().🟠 [Partial]: Recovery-code consumption must be persisted transactionally by the application. The two-replica deployment acceptance (PR #224) shows two processes sharing one Redis rate-limit budget; Redis eviction and failover evidence is still required. CSP nonce composition is shared, but Core and Security are not yet one canonical Server stack; WebSocket CSRF tickets/frame crypto, trusted rollback checkpoints, spool compaction/remote acknowledgement and external SIEM delivery are not implemented. |
rullst-ai | Multi-provider LLM client (Gemini, OpenAI, Claude, DeepSeek, Ollama, explicit OpenAI-compatible endpoints), prompt injection defenses, PII masking, bounded tenant-aware RAG, guarded local tools, and conversational memory. | 🟢 [Implemented / Bounded]: Guarded AiClient, heuristic prompt filter, PII masking, machine-readable provider capabilities, configurable bounded live-request deadlines, a versioned deterministic injection/jailbreak/PII regression corpus, and a capability-declared OpenAI-compatible adapter. The adapter separates literal-loopback-IP HTTP(S) from HTTPS cloud configuration, supports optional Bearer authentication, disables ambient proxies/redirects, and bounds image/response bodies; unrelated protocols use AiProvider. A separate static-dispatch StreamingAiClient<P> enforces chunk/output ceilings and explicit cancellation; exact OpenAI-compatible configurations may declare strict incremental SSE with a required terminal marker and cancellation raced against request/body reads. AdaptiveAiEvaluator<P> runs caller-defined multi-turn strategies with independent turn/prompt/response/deadline limits, cancellation, typed pass/fail/inconclusive decisions and a versioned report that retains no raw prompt, response or provider error. Repository fixtures prove orchestration, not live-model behavior.🟢 [Implemented / Bounded]: Strict URL/resolved-IP/redirect/resource policy plus an opt-in deny-by-default HTTPS fetcher with exact-host allowlist, DNS pinning, proxy bypass, peer verification and streaming limits. Explicit vision helpers accept application-admitted bytes, canonical exact-root local files or URLs only through that fetcher; capability and prompt checks precede I/O, input is capped at 10 MiB, supported image signatures are sniffed and a supplied remote media type must match. Local tool dispatch separately requires allowlist, principal authorization, closed bounded JSON, call budget, audit sink, and payload-bound approval for destructive/financial calls. RagPipeline::answer composes guarded embedding, a static-dispatch application retriever, Unicode-safe context budgets, guarded generation, source metadata, and required secret-minimized terminal audit under a trusted TenantContext; a bounded tenant-partitioned process-local cosine retriever supplies the offline contract. DurableRagAuditTrail and DurableToolAuditTrail synchronously append minimized events to distinct versioned local files under byte/record quotas and fail closed on restart corruption, symlink targets, competing-writer growth or durability uncertainty. Their SHA-256 frames detect corruption but do not authenticate events. The separate opt-in AuditDeliveryClient exports a caller-minimized, at-most-16-KiB JSON envelope with an exact HMAC-SHA256 signature, key/timestamp metadata, stable event identity across bounded transient retries, explicit cancellation and a closed event-bound acknowledgement. Cloud delivery requires HTTPS and literal-loopback HTTP(S) is development-only; the receiver still owns freshness verification, deduplication, authorization, persistence, retention and key operations.🟢 [Implemented / Feature-gated]: StatefulChat<M> loads bounded tenant/conversation history, performs guarded generation and atomically appends one user/assistant exchange through a static ChatMemory. The bounded in-memory store is always available; sql-memory supplies fixed-schema SQLite/PostgreSQL/MySQL/MariaDB storage with an even monotonic revision and transactional compare-and-swap, so stale cross-process writers fail instead of silently reordering. Raw message encryption/retention, authenticated ownership within a tenant, provider audit, backups and conflict UX remain application contracts; the CLI scaffold remains the Turso/custom-model path.🟠 [Partial]: The egress fetcher is not automatically mounted around provider transports, RAG or arbitrary application clients; hosted-provider SSE conformance, non-compatible streaming protocols, image decoder safety, host path trust/authorization, provider-native tool calling, cancellation for ordinary non-streaming calls, automatic provider retries, durable audit outbox/receiver operations, approver authentication, first-party external vector-store retrievers, authoritative datastore/domain authorization, ingestion/deletion, maintained application-specific eval corpora and output policy remain application or roadmap work. Exact live-model evaluation execution/results and provider behavior remain external evidence.🟡 [Offline Mock]: Deterministic offline chat/vision/embedding fallbacks. |
rullst-capital | Provider-neutral SaaS billing contracts, SaaS MRR/ARR metrics, verified webhooks, quotas and entitlements, with a supported Stripe adapter and an experimental InfinitePay adapter. | 🟢 [Implemented / Bounded]: BillingProvider/MeteredBillingProvider contracts, the Stripe adapter, pooled fail-closed HTTP clients, explicit mock credentials, and signature/freshness/replay foundations for the methods documented by each adapter. Applications integrate other gateways by implementing these traits (custom providers).🟢 [Implemented / Feature-gated]: webhook-sql persists bounded provider-scoped payload digests or caller-supplied stable event identifiers across processes on SQLite, PostgreSQL, MySQL, and MariaDB. Immutable capacity/TTL, serialized claims, expiry, restart, contention, configuration drift, and fail-closed full/storage states have executable evidence. A caller-owned relational transaction can bind one semantic event claim to its domain mutation. Middleware admission and cross-system exactly-once are not implied; external effects still require an outbox, idempotent consumers, and reconciliation.🟢 [Implemented / Bounded]: Static-dispatch metered billing uses the current Stripe Meter Events request shape, binds accepted responses to the original event, caps response bodies, forwards a bounded provider identifier and exposes deterministic non-live mocks.🟡 [Experimental]: InfinitePay keeps deterministic offline fixtures; live plan-only checkout and live callbacks fail closed until validated against a live account.🟠 [Partial]: Uniform live method coverage, provider-account interoperability, cross-system exactly-once, and reconciliation are incomplete.⚪ [Removed in v13]: Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce and PicPay adapters, the Wise payout adapter and payout contracts, and the NFS-e fiscal preparation pipeline; see the migration guide. |
rullst-connect | Social login / OAuth2 / OIDC providers (Google, Apple, GitHub, Discord, Microsoft, Facebook, LinkedIn, X, Auth0, Cognito and generic OIDC) with PKCE and rotating JWKS. | 🟢 [Implemented / Bounded]: OAuth2/OIDC clients with constant-time PKCE comparison, validated discovery, bounded JWKS refresh/cache policy (the generic OidcProvider verifies a token without kid only against a single-key JWK Set whose key fits its asymmetric alg, per OIDC Core 10.1, and treats profile claims such as name as optional), deterministic mock credentials (empty or mock_* values, served only with the mock feature; without it the fallible constructors reject them as InvalidConfiguration for credentials and the deprecated ones return a disabled provider; the mock authorization URL is the configured callback with the fixed code mock_authorization_code and the state) and a credential-free UniversalProfile projection. ConnectUser serialization omits access/refresh tokens; deserializing that form restores a credential-free profile with an empty access token. Category-aware remote revocation rejects malformed or oversized tokens before transport: Google, Discord and Apple accept the documented access/refresh categories, GitHub accepts access tokens, and Auth0/Cognito accept refresh tokens; protocol fixtures bind method, endpoint, client authentication and form/JSON shape, while request/response Debug omits credentials, bodies and URL query data. Other providers fail explicitly as unsupported, and remote success does not clear application sessions or persistence. AutoRefreshingSession<P> validates and user-binds token generations, detects expiry with a bounded early-refresh window, serializes provider refresh through static dispatch, retains/rotates refresh credentials and swaps the access token only after a complete valid response; a rejected same-user response, or a ConnectError::RefreshIncomplete whose profile/ID-token step failed after the grant (carrying the issued tokens to direct callers), still keeps its valid rotated refresh token and advances the generation so the consumed credential is never retained. Callers waiting behind a successful refresh reuse that state. Its state/leases redact secrets. EncryptedTokenSnapshot supplies a bounded, versioned AES-256-GCM envelope that authenticates key ID, provider and trusted local-account binding, preserves the validated generation and rejects copied-owner/tampered records. The optional sqlite store persists only a pseudonymous binding digest, generation/key metadata and that ciphertext under an immutable row ceiling; BEGIN IMMEDIATE, exact-successor compare-and-swap and conditional deletion reject stale shared-local writers, with restart, contention, quota, configuration, corruption, key and symlink evidence. The application still owns secret-manager key custody/rotation, account authorization, a lease around the remote provider call, running an in-flight refresh to completion (dropping the access_token() future after the grant loses a rotation), losing-call reconciliation, retry/backoff, trusted directory/backup, reauthentication and multi-host replication. The optional Axum/tower-sessions lifecycle generates a ten-minute state + PKCE challenge, adds nonce for OIDC, keeps verifier/nonce server-side, removes and immediately saves the sole active challenge before validation and rejects sequential replay/expiry/mismatch. The host still owns durable session storage and cookie/TLS/account policy; the generic session-store API is not distributed compare-and-delete, so simultaneous already-loaded callbacks require idempotent effects or a stronger application store. The v13 challenge module (from 13.0.0-alpha.3) is that stronger store without a session layer: begin_login keeps the challenge in a ChallengeStore (bounded memory, or SqlChallengeStore over the application’s SQLite, PostgreSQL or MySQL pool with the sqlite, postgres or mysql feature, which creates rullst_connect_login_challenges) under a SHA-256 digest of an opaque 256-bit ChallengeId; ChallengeCookie carries only that id (HttpOnly, SameSite=Lax, at most ten minutes, path-scoped, ambiguous duplicates refused); finish_login consumes it with one atomic removal (DELETE … RETURNING, or SELECT … FOR UPDATE then DELETE on MySQL) so exactly one of concurrent callbacks wins, refuses expired, altered and other-provider challenges, compares the state in constant time before trusting a provider error and passes the stored verifier and nonce to the exchange. ChallengeCookie::for_cross_site_post (v13) makes the cookie SameSite=None; Secure for a provider that POSTs the callback cross-site (response_mode=form_post). From 13.0.0-alpha.3 GithubProvider reports the primary address of GET /user/emails with GitHub’s verified flag (falling back to the public profile email with email_verified: None when the call fails or lists no primary), MicrosoftOidcProvider signs in with the Microsoft identity platform for an explicit MicrosoftTenant (RS256 ID token against the tenant keys, issuer bound to its tid, tenant admission, subject <tid>:<oid>, email_verified only with xms_edov: true), and AppleProvider adds verify_signing_key (the ES256 client secret can be signed) and name_from_user_field (the bounded, unsigned first-login name). Entry limits are exact on SQLite and in memory and may be exceeded by concurrent saves on PostgreSQL and MySQL. SQLite contracts run in the workspace tests; the PostgreSQL and MySQL contracts run against owned servers in CI (check-connect-sql.py). ReqwestClient also exposes explicit HTTP(S) corporate-proxy constructors, which OidcProvider::discover_with_client also uses for discovery: endpoint shape is bounded, URL credentials are rejected, authenticated remote proxies require HTTPS, system-proxy lookup is disabled and a local protocol fixture proves routing/auth headers.🟢 [Implemented / Local Test Fixture]: The explicitly mounted Axum Mock IdP accepts only configured HTTP-loopback issuer/callback origins, binds one exact client, bounds process-local grants/tokens, consumes expiring authorization codes once, verifies S256 PKCE, signs nonce-bound EdDSA ID tokens and publishes discovery/JWKS. The deterministic key and credentials are public test fixtures; interactive login/consent, refresh/device/federation flows, durability, rotation, public exposure and OIDC conformance are not claimed.🔵 [Roadmap]: PAC/WPAD, SOCKS, proxy mTLS identity and enterprise deployment certification are not implied. Message brokers live in rullst-messaging, not this OAuth-focused crate. |
rullst-messaging | Broker-neutral event envelopes, idempotent publication, consumer groups, acknowledgement leases, retry, dead letters, durable local SQLite state, and future remote adapters. | 🟢 [Implemented / Bounded Foundation]: rullst.messaging.v1 envelopes, bounded identifiers/headers/payloads/batches/retention, topic-scoped exact-replay idempotency, fan-out between groups, competing consumers, expiring single-use ACK leases, bounded retry/attempt ceilings, dead-letter views, explicit terminal purge, injectable time and a reusable static-dispatch contract suite. Debug output redacts keys/tokens/header values/payloads. A canonical bounded v1 envelope codec rejects unknown versions, non-canonical/truncated/oversized frames and namespace mismatch; a deterministic digest fixture freezes its bytes. Validated W3C version-00 traceparent and a conservative tracestate subset propagate through only those two allowlisted headers; arbitrary baggage, sampling and export remain host work. InMemoryBroker remains deterministic/process-local. The opt-in SqliteBroker uses a fixed schema and serialized BEGIN IMMEDIATE mutations for publications, subscriptions, claims, ACK/retry/DLQ and purge; exact limits are persisted per namespace. Its explicit AES-256-GCM profile encrypts header values plus payload with randomized nonces and AAD binding to immutable row metadata. A bounded primary/prior-key ring rejects missing keys until old records are purged, and plaintext/encrypted profiles cannot mix. The opt-in static OrmOutboxRelay binds one relational outbox stream to one topic, validates claimed JSON, publishes the durable event key scoped by its stream (SHA-256 of the stream, /, event key) as broker idempotency, so streams sharing a topic never deduplicate against each other, and only then ACKs the exact ORM lease; a publish-before-ACK crash/reclaim test produces an exact replay and one broker message. Shared-contract, raw-storage/restart, wrong-key/tamper/row-swap, symlink, rotation, expired-lease, two-instance contention, configuration-drift and malformed-row repair regressions are executable.🟠 [Partial]: Delivery is at least once. The default profile is plaintext. Even in the encrypted profile, topic/event/content metadata, IDs, timestamps, idempotency keys, fingerprints, rotation key IDs and delivery state remain visible; key custody, permissions, backup/rollback detection, retention, disk operations, topic/tenant authorization and destination-side idempotency belong to the host. Profile migration requires a new namespace/database and application-owned republishing. The outbox database and broker publication are not one atomic transaction; worker supervision, cleanup and destination idempotency remain application work. The local adapter does not provide replication or automatic failover. The envelope codec is not a remote transport and does not preserve caller publication keys or broker acknowledgements by itself. The optional standalone Redis Streams candidate passed source admission in PR #236, with Rullst-owned indexes, verified TLS, exact replay, server-time lease fencing, partial-write quarantine and disposable restart/fault/outbox tests; final release admission remains separate (see the Redis messaging guide below). Kafka, RabbitMQ, NATS/JetStream, SQS/SNS, Google Pub/Sub and Pulsar adapters, replication/failover and native Redis group interoperability remain roadmap work. |
rullst-iot | no_std sensor telemetry/protocol helpers and an Ed25519-signed firmware-manifest verification gate. | 🟢 [Implemented / Bounded]: Ed25519 manifest verification with target/hash/length/counter checks, an explicit durable monotonic-CAS store boundary, no_std telemetry/frame models, bounded MQTT 5 PUBLISH and RFC 7252 CoAP base-request encoders, a credential-free local HTML snapshot renderer, and a safe telemetry-module CLI scaffold. Protocol vectors and restart/retry/conflict tests prove these local contracts, not a broker, network or physical device.🟠 [Partial]: GPIO state, I2C/Modbus frames, BLE GATT records, RSSI topology, power recommendation and Digital Twin JSON are data/helpers only, not hardware, network or realtime drivers.🟡 [Simulador Dev]: Deterministic MQTT-value/HSM/PQC fixtures require feature = "experimental-simulators" and never represent broker or cryptographic capabilities.🔵 [Roadmap]: Concrete hardware-backed counter/boot integration, firmware download/flashing, MQTT/CoAP transports and state machines, hardware drivers/HSM and audited ML-KEM. |
rullst-mail | Transactional email engine with Resend, SendPulse, optional SMTP (implicit TLS on 465, mandatory STARTTLS on other ports), optional native AWS SES v2, and offline fixtures. | 🟢 [Implemented / Bounded]: Mandatory pre-flight pipeline, anti-CRLF validation, a 2 KiB subject and 2 MiB per-body bound enforced by rejection before linear-time disposable-domain/security/DLP heuristics, provider-specific transports (other providers are application-owned MailDriver transports; v13 removed SendGrid, Postmark, Mailjet, Mailtrap and ACS), seven safe scaffold variants (including bounded commercial payment receipts and explicit D+1/D+3/D+7 dunning), and expiring purpose-bound HMAC tracking tokens. TenantMailResolver selects an in-process driver directly from an explicit authenticated Core TenantContext; invalid IDs and unavailable registry state fail closed, and tests prove two contexts do not cross-deliver. MailError classifies permanent/transient/rate-limit outcomes; the in-process FailoverDriver forwards tenant context and delivery IDs to each driver it tries and sends another provider only transport/HTTP 5xx/429/transient-SMTP failures, stops at a fallback’s permanent or fail-closed guard error and returns it typed, captures bounded delta Retry-After, fails closed on circuit-state errors and emits structured tracing without provider response bodies. The Mail facade fills a missing sender from a validated MAIL_FROM/[mail] from default (v13) and real transports never invent one. Mail::enqueue preserves tenant and bounded due-time metadata through SQLite/Redis without early claims; the worker consumes that timestamp only once it is due, allowing at most 300 s of worker clock lag behind the queue server. Direct Resend retains provider-native scheduling and rejects schedules beyond its documented 30-day window before network, while real SMTP/SendPulse/Log and SES paths reject future direct delivery; offline fixtures may retain it for assertions. The shared attachment contract accepts at most 32 items, 20 MiB each and 25 MiB raw aggregate; validates safe basenames, parameter-free MIME and unique HTML-referenced CIDs; redacts bytes from Debug; and feeds provider-native Resend, SendPulse (non-inline), native SES, the SES v2 bearer-proxy Simple.Attachments payload and nested SMTP MIME serialization. The opt-in static AttachmentInspectionGuard fails before transport on executable magic, executable/script-host extensions, spoofed known types, active PDF/SVG, recognized secrets and unsafe text links, choosing checks from the case-insensitive declared type, filename extension and content signature together, and classifying markup by root element and namespace as browsers parse it (strict mode also rejects HTML extensions/markup, script URIs, unknown extensions and declared types it does not inspect other than application/octet-stream); external scanners can implement the same contract. The provider-neutral SuppressionGuard checks process-local or opt-in shared-local SQLite state before transport, keyed by the bare address that one recipient parser extracts for the pipeline, the disposable-domain check, verified feedback and every store (display-name forms are normalized, internationalized domains are keyed by their IDNA A-label, unparsable forms and non-ASCII local parts fail closed as validation errors); verified event identities are replay-bound, suppression reasons escalate monotonically and immutable quotas are transactional. ObservedMailDriver emits only a bounded provider label, terminal outcome, latency, attachment count and scheduling/tenant booleans through a non-failing observer. With aws-ses, AwsSesDriver sends SES v2 Simple messages through the official AWS SDK and SigV4, including temporary credentials, caller-owned rotating providers/config, HTML/text, RFC 8058 headers and attachments/CID; it rejects provider field limits and an encoded estimate over 40 MiB before network, caps Retry-After, and a loopback contract asserts the signed regional ses/aws4_request request plus typed/redacted rejection. The legacy constructor remains only an offline-fixture or explicit trusted bearer-proxy boundary, never an unsigned AWS request; the proxy payload carries attachments/CID under the same SES field and 40 MiB bounds instead of dropping them. Payment receipts never claim tax authorization; dunning does not infer billing state or scheduling.🟠 [Partial]: Exact execution time, exactly-once delivery, live-account SES acceptance and inbox delivery are not implied. The local attachment inspector is not antivirus, sandboxing, recursive archive inspection or CDR; provider/account limits may be tighter. Provider webhook authentication/adapters, multi-host suppression replication, file encryption, distributed breaker/telemetry operations, durable encrypted tenant credentials, rotation and cross-process distribution remain application/deployment concerns; tracking payloads are authenticated but not confidential. SES identity/domain verification, sandbox exit, IAM least privilege, quotas, reputation and provider operations remain AWS/account/deployment work.🟡 [Offline Mock]: Memory/Log plus empty or mock_* provider credentials; SMTP only for an empty/mock_* host or a mock_* credential, so a real host without credentials is a real unauthenticated relay and partial SMTP credentials are rejected. |
rullst-studio | Local Developer Control Room (http://127.0.0.1:5555), clean route navigation, live system telemetry visualizers. | 🟢 [Implemented / Bounded]: Local control center, RadarSnapshot telemetry, database/migration surfaces when configured, and explicit Unavailable states for unconnected probes. The data browser reads/filters SQLx tables with 25-row pages, 256-character cells and 256-byte search terms and, only after the verified debug-loopback/same-origin middleware installs an unforgeable request marker, can update primitive non-key values or delete exactly one complete-primary-key-selected row. A table whose key includes a column outside the identifier boundary or column cap stays read-only, and each write commits in a transaction only when exactly one row changed. Values are bound, request/schema/value cardinality is bounded, backend-specific types remain read-only and SQLite/PostgreSQL/MySQL/MariaDB have executable mutation contracts. This is not application tenant/RBAC, audit, rollback or shared-production administration. The supplied queue snapshot exposes only backend records; SQLite can explicitly retain 1–100,000 successful jobs with atomic pruning and purge while deleting them by default. Retained payload access/policy belongs to the host. An explicitly supplied memory/Redis Cache exposes at most 100 metadata rows in the UI; logical keys become process-bound HMAC tokens, values never leave the driver, and only individual local invalidation is available. A separately mounted push-only trace router accepts 1–128 attribute-free v1 spans under 128 KiB after HMAC-SHA256, source/ID/clock/nonce validation and atomic replay rejection; the bounded in-process viewer derives slow-query and repeated-label heuristics without SQL or bindings. It is not OTLP, durable trace storage, a key manager or remote Studio authentication. Successful feature-flag toggles invalidate all warm DbFeatureDriver caches in the same process through a constant-size epoch. Cross-process/direct-writer invalidation remains TTL-bound unless the application distributes the signal. |
rullst-nexus | Auto-generated Admin CMS (/nexus), dynamic model CRUD, AI Admin Assistant (/nexus/chat, ai feature; rullst facade nexus-ai), SOC Threat Radar. | 🟢 [Implemented / Bounded]: #[derive(Nexus)] emits registered named-field metadata with inferred primitive or explicit semantic widgets; the panel provides parameterized CRUD/search/sort/pagination plus bounded selected-record delete/deactivate. Construction is fail-closed, requires an authentication policy and admin role layer, validates bounded unambiguous model/field/enum/relation metadata, enforces server-side field policy, caps form pairs and field bytes, rejects unknown/protected/duplicate or semantically invalid form values, minimizes database errors returned to clients, and escapes record/model metadata on audited paths. Boolean widgets are inferred; enum options and multiline intent are explicit because an unrelated Rust field type does not expose those semantics to the struct derive. Deactivation requires a writable Boolean is_active/active. In v13 an opt-in application policy admits each request through the host’s own sign-in (NexusAuthorizer), models may declare composite primary keys, and date/time fields bind typed values that native PostgreSQL/MySQL date and time columns accept.🟢 [Implemented / Opt-in Bounded]: a registered text tenant column scopes every built-in read, create, update, delete and batch operation to a trusted Core TenantContext; create injects the context value and missing context fails closed. with_required_audit transactionally couples successful mutations to a minimized fixed-schema row containing the built-in authenticated actor, optional tenant, table/action, optional known key, count, committed outcome, correlation ID, timestamp and format version; missing audit storage rolls back the mutation. The audit table is in the same relational database, mutable by its administrators, records no denied attempts, and may omit an automatically generated create key. Host identity/membership/domain policy, global-model and custom-route authorization, database privileges, schema/type compatibility, retention/backup/replication and immutable external audit delivery remain application/deployment contracts. |
rullst-macros | Procedural macros (html!, #[server_function], #[island], #[live_component]/#[live_event], #[memoize], #[require_role], #[derive(Billable)]) and the deprecated #[route] compatibility marker. ORM derives live in rullst-orm-macros; rullst::runtime::main re-exports Tokio’s entry-point macro. | 🟢 [Implemented / Bounded]: Compile-time html! escaping into composable Markup with explicit RawHtml, role/billing/live/memoize attributes, and trybuild diagnostics. A concrete async #[server_function] returning RpcResult<T> generates a matching explicit native router and Wasm caller over the bounded rullst.client v1 JSON envelope: owned Serde parameters/results, same-origin /api/rpc/... path, 256 KiB request/response policy, request correlation, media-type/version/schema checks, CSRF-cookie forwarding and message-free failure codes. The host must mount the route inside production security, authenticated identity, tenant, authorization and rate-limit layers; application idempotency and browser/network interoperability beyond CI are not inferred. #[island] hydration remains experimental. |
cargo-rullst | Developer CLI toolkit, scaffolding generators (make:*), three project starters (Blank, API, SaaS), AST IDOR static route scanner. | 🟢 [Implemented / Bounded]: Interactive wizard, generators, heuristic IDOR scanner, CycloneDX exporter, toolchain doctor and a fail-closed Academy evidence diagnostic that explicitly does not certify a deployment. Deterministic generation can explicitly select the starter (`–blueprint blank |
The matrix omits rullst-orm-macros, which ships the ORM derives described in
section 5, and the Experimental v13 crates rullst-privacy, rullst-supervision,
rullst-media and rullst-labs (all four in the release inventory and first
published in the 13.0.0-alpha.1 prerelease); the v13 sections below specify
their boundaries.
v13 roadmap package boundaries
The rullst-labs library is an implementation
candidate in the 13.0 release inventory, first published in the
13.0.0-alpha.1 prerelease; final release acceptance remains outstanding. It is a
bring-your-own-runner library: it owns trusted, versioned orchestration and
grading contracts and never executes learner code. The separately deployed
rullst-labs-runner candidate (a Linux Rust-to-Wasm/Wasmi executor that passed
source admission in PR #228, with
recorded hosted evidence)
was removed from the workspace for 13.0; its source remains in git history.
Applications deploy their own runner against the documented
controller contract. Neither rullst-labs nor any
runner may become a default framework dependency, execute learner code inside
the HTTP process, or require the application to expose a container control
socket. A complete offensive CTF arena is external, separately governed
deployment infrastructure even when it uses Rullst identity, challenge, score
and receipt contracts.
Protocol version 1 defines one profile: a bounded Rust pure-function exercise
(solve(i64, i64) -> i64) whose identifiers name a pinned Rust toolchain,
import-free WebAssembly and a pinned Wasmi interpreter. This is not a native
Rullst server, Cargo dependency, WASI or arbitrary shell profile.
rullst-labs must not depend on an executor or spawn submitted code; the
workspace boundary check rejects an execution-engine dependency in any member.
Its default surface is validated contracts; opt-in shared-local SQLite stores
dedicated, encrypted job content with current application authorization,
idempotent submission, cancellation, leased execution, retention and result
reconciliation. It must not reuse the application’s authentication/database
secrets as job keys. Keep both existing 90% line-coverage floors; rullst-labs
remains in the framework-library aggregate.
The application-owned runner’s trusted controller accesses only that dedicated
job plane, its receipt signing seed and runner-owned tools. It must never give
submitted code the job database, signing keys, expected answers, application
secrets, inherited environment, network or control sockets. Keep the content
key and signing seed in owned private regular files under protected ancestors,
and validate the opened no-follow descriptor and exact key length. Execution
isolation, resource limits, observed enforcement and teardown are host
responsibilities: rullst-labs binds pinned tool digests and verifies signed
receipts but cannot check that isolation happened. Unsupported environments
must fail closed without a less restrictive execution fallback. The removed
candidate’s first-profile threat model records one
reviewed Linux design (delegated cgroups v2, unprivileged namespaces, seccomp,
separate Landlock domains and observed probes) as a reference baseline.
Only an integrity-bound result matching the current job/lease, tenant, learner, exercise, grader, toolchain, source and execution profile may become a grade. Expected answers remain in the trusted grader; worker outputs are bounded data, not a passing-grade authority. Cancellation/expiry fence late results; worker loss never means success. Offline simulation is explicit and cannot establish execution evidence. Authorized course maintenance must expire queued submissions and remove their source without requiring a supported/available executor. It must not clear a running lease or infer teardown. Withdrawn exercise revisions may be removed only after every referencing job has been purged; immutable revision identifiers must not be reused after removal. Independent isolation review of the deployed runner and the roadmap’s adversarial acceptance remain required before any production-ready untrusted-code claim.
v13 managed-video implementation boundary
The optional rullst-media candidate, first published in the 13.0.0-alpha.1
prerelease, owns managed private video,
starting with Bunny Stream. This differs from Core’s object-storage facade:
remote video creation, upload capabilities, asynchronous processing, playback
grants and lifecycle reconciliation require a separate explicit domain.
It has no Core/Auth/ORM runtime dependency and no default network/database features.
Core is a test-only dependency for the real HTTP/browser security composition.
bunny selects the reviewed HTTP/signature adapter; sqlite selects durable
shared-local asset/operation state and static-dispatch application orchestration.
PRs #227 and #239 supply hosted source and extracted-package evidence. The
standalone package now enters the v13 release inventory, without a facade
dependency. It was first published in the 13.0.0-alpha.1 prerelease; final
release acceptance remains outstanding. Actual
Bunny account/CDN interoperability remains explicitly unvalidated.
The host supplies authenticated actor/course membership and current management
or playback entitlement through a checked authorization trait. Local asset IDs
bind an immutable tenant/course/provider-library scope; provider IDs and signed
webhooks never establish that ownership. Service operations recheck permission
and local revision/state after external work and use bounded durable leases to
reject concurrent/stale mutation results. Playback is a read: it takes no lease,
so viewers never wait on one another’s provider reads and an abandoned request
leaves no intent; its short local transactions still serialize, and a lock or
connection wait over 5 seconds is Busy; it fences withdrawal/deletion in the transaction that issues the grant. SQLite state binds its schema, provider mode,
library, store-wide capacity and an optional per-tenant asset quota for shared
stores, refuses clock rollback and requires trusted local files,
backup policy and operator-owned keys. Multi-host replication is separate work.
Only confirmed-deleted or explicitly discarded local tombstones may be purged,
in batches up to 100 and after at least 24 hours; the host must retire purged
creation IDs because their idempotency memory ends at that point. Tombstones
keep no digest of the deleted title or description. Provider backups/cache erasure is separate.
Creation is journaled before remote dispatch. Bunny’s documented creation API
does not supply an idempotency key: ambiguous creation must reconcile a persisted
random opaque creation marker, never blindly retry or claim exactly-once remote
creation. Updates/deletion are reconciled against authoritative reads; a
metadata update writes and verifies the same documented description meta tag,
never Bunny’s separate top-level Description that Smart Generate may rewrite.
A create or update that fails for a non-transient reason (definitive refusal,
unconfirmed marker search, missing remote video, full tag list or failed
verification) stops with a bounded non-secret reason instead of staying pending;
it is never retried automatically and an authorized host explicitly retries or
discards it. Retrying an unconfirmed creation repeats only the marker search;
discarding a creation leaves a local tombstone and may orphan an undiscovered
remote video. Webhook
v1 authenticates exact body bytes with the read-only library key but has no
signed timestamp; bounded durable duplicate suppression and serialized provider
refresh prevent replay/reordering from granting access or publishing assets.
Webhook status numbers and API video status numbers are distinct protocols.
Publication is an explicit application mutation after current provider readiness; processing completion alone cannot publish. Playback requires current entitlement, fresh ready state and configured provider protection; withdrawal/deletion stop new grants but cannot immediately invalidate previously issued bearer tokens or provider caches. Upload grants are scoped and expiring, not cryptographic proofs of a one-shot upload, file type or byte limit. Library upload quotas and direct file protection remain required configuration. Embed SHA-256, TUS SHA-256 and CDN advanced HMAC-SHA256 directory tokens have independent documented formats. Redirects/ambient proxies/arbitrary remote endpoints are disabled; request, response, retry and total-operation budgets are finite. Secret-bearing errors, credentials, grants and debug output must not leak keys or bearer URLs.
Empty or mock_* credentials select deterministic offline behavior; mixed modes
fail configuration. Production rejects mock or loopback-fixture capabilities.
Automated protocol/browser/disposable-state evidence is required. The owner’s
no-live-account-testing instruction remains in force: actual Bunny account,
transcoding/CDN interoperability and paid DRM are not validated or implied.
See the supported delivery target.
The experimental s3 feature adds S3-compatible object storage (AWS S3,
Cloudflare R2, MinIO) behind the same provider, grant and SQLite contracts. It
stores and serves the original object without transcoding. Keys are
server-generated; uploads use host-authorized presigned PUT grants bound to a
declared content type and exact length, playback uses presigned GET
(PlaybackKind::Original), both expire within 900 seconds, and HEAD confirms
size and type before an asset becomes ready. Signing reuses Core’s aws-sigv4
signer, not a second S3 SDK. It is validated against the offline mock and a
loopback protocol fixture only; provider interoperability is not yet validated.
See S3-compatible object storage.
v13 privacy and age-assurance boundary
Core and Security header layers share apply_referrer_policy: a response that
explicitly supplies a canonical no-referrer value retains that restriction
through composition, normalized to one header even if duplicate values exist.
The Security layers replace other values with their configured policy;
missing/invalid optional Security configuration retains its existing behavior.
The Core baseline adds each security header, including Referrer-Policy, only
when the response has none, so an explicit application value wins over it, and
it leaves every security header alone on a response that an inner
SecureHeadersLayer marked with SecurityHeadersApplied (omitted headers stay
absent; Cache-Control: no-store and the shared CspNonce are unchanged).
This narrow rule does not attempt to order every Referrer-Policy value.
rullst-privacy is an opt-in v13 release candidate, first published in the
13.0.0-alpha.1 prerelease. Its initial
age-assurance feature owns bounded risk policies, server-issued challenges,
minimal signed age attestations, explicit decisions and replay-store contracts.
Low-risk declarations, facial estimates and verified age attributes have
different assurance semantics; configured policy determines their eligibility.
No camera, image retention, external inference, database or Core dependency is
enabled implicitly. Production must reject offline mock evidence and
process-local replay protection. The host owns authentication, tenant/subject
binding, risk/legal assessment and durable shared state.
The v13 persistence contract uses static-dispatch asynchronous nonce claims.
Verification samples a trusted server clock before validation and after the
claim; expiry or clock rollback during storage cannot return permission.
A claim that the high-water check rejected only because a concurrent request
committed a later second is repeated, at most twice, with a fresh sample that
has advanced past the rejected one; the check itself is never relaxed.
The optional sqlite adapter is shared-local only: a private file-backed pool,
WAL with full synchronization, serialized quota/expiry/claim transactions and
persisted configuration/clock high-water state. It stores only domain-separated
nonce digests and expiry. No unexpired claim may be evicted. Cancellation or an
uncertain commit returns no assessment; the host must request fresh evidence
when consumption is uncertain. File custody, clock synchronization and storage
durability are deployment obligations. Restoring an older database requires
quiescing verification and invalidating all outstanding challenges through a
new policy/key epoch; SQLite cannot detect arbitrary backup rollback. Network
filesystems and multi-host replication are outside this adapter’s boundary.
The v13 PostgreSQL adapter uses a separately enabled postgres feature and a
private bounded pool against one authoritative writable database. Explicit
deployment initialization creates the fixed rullst_age_replay schema; normal
connection never creates or repairs missing state. Transactional startup locking
and a metadata row lock serialize schema initialization and quota/expiry/nonce
claims across application hosts. Metadata persists the schema version, capacity
and accepted clock high-water mark. Tables must be permanent and WAL-logged;
fsync, full-page writes and synchronous commit are required. Claims store only
the same domain-separated nonce digest and expiry as SQLite. Remote connections
require certificate/hostname-verified TLS; loopback and local sockets support
disposable development databases. Errors must not expose connection credentials.
Bounded pool acquisition, statement/lock waits and idle transactions prevent
unbounded database waits; the caller still owns an overall request deadline.
All verifiers must use that same authoritative database and synchronized trusted
clocks. Replication configuration, failover fencing, durable hardware and backup
restore remain operator obligations; asynchronous replica promotion or database
rollback cannot be advertised as preserving consumed proofs automatically.
The adapter’s focused real-database tests cover concurrent initialization and
claims, a restricted runtime role, quota, schema/durability drift, cancellation,
clock changes during a row-lock wait and server restart. Combined hosted
acceptance and deployment-specific failover/TLS evidence remain separate.
This first contract does not implement a facial model, vendor transport, guardian verification or global privacy compliance. The privacy and age-assurance roadmap defines the remaining consent, rights, retention, provider and jurisdiction work. The owner-approved 13.0.0 delivery scope prioritizes the independent foundation and an authenticated first-party declaration journey for policies that permit that assurance. An external provider is optional and requires separate native protocol and sandbox acceptance before any live claim. A local facial engine is follow-up work. Neither an absent provider nor a failed check may downgrade the required assurance or make a declaration authorize a stronger-policy action. Publication of the privacy package still requires explicit acceptance of its advertised API, durable state, consumer behavior and release configuration.
The release inventory (.github/release-order.json, 20 packages) lists this
independent package before the umbrella. The facade exposes only explicit
privacy-* features and rullst::privacy; default builds acquire no privacy,
age, consent or database dependency from this addition. CLI age/privacy consumers
select the matching registry version by default, with an explicit matching local
source override for development. Existing dependency sources, versions and
features must be checked before composing two consumers; no silent source switch
is permitted. Both commands refresh the bounded project context after scaffolding,
reporting a refresh failure without concealing the completed source edits.
Archive-only acceptance must install the packaged CLI and compile
generated SaaS consumers with both opt-ins and no workspace source paths.
The native DeclarationGate contract processes an authenticated first-party
AgeDeclaration without requiring an external issuer or a signing key for the
declaration itself. It accepts only a retained server-issued SelfDeclaration
challenge under the exact current policy and tenant/subject/session/action
binding. It shares the signed verifier’s durable one-use claim and post-storage
expiry/clock checks. An affirmative answer can return only Assurance::Declared;
negative or declined answers deny the operation. An estimated/verified method
or stronger policy cannot be downgraded through this entry point. The host owns
authentication, CSRF protection, explicit user choice and trusted challenge
retention/transport. This contract is not a determination of the person’s age.
The optional challenge-tokens transport uses a bounded, versioned,
HMAC-SHA256-authenticated server challenge with an explicit active key and at
most seven previous verification keys. It authenticates the version, key ID and
exact encoded payload before decoding challenge JSON. Opening revalidates the
current policy, authenticated binding, clock and exact configured lifetime.
There is no client-supplied algorithm or key discovery. Tokens contain only the
existing opaque challenge references and are authenticated, not encrypted;
applications must not put cookies, email addresses or document numbers into
those references. A token restores a server-issued challenge, never an age
decision or an authorization grant. Production still consumes its nonce in the
shared durable replay store. The host owns independent secret provisioning,
rotation/retirement, TLS, CSRF and endpoint limits; no external age provider is
needed for this transport.
The v13 make:age-gate consumer is an explicit opt-in for the recognized SaaS
starter. It protects the existing dashboard action with an authenticated GET
challenge and POST declaration; it never sets a reusable age-verified account
flag. Both routes retain the starter’s authentication, CSRF, headers and Server
baseline. Policy version and threshold are chosen explicitly at generation time;
the SaaS profile also requires its fixed application tenant. Low-assurance
declarations are identified as such. Opaque
binding references are domain-separated keyed digests of server-resolved user,
tenant and authenticated session, never request-supplied identities. Application
keys and replay storage are mandatory; initialization, timeout, stale context or
unknown/negative answers cannot grant access. SQLite and PostgreSQL are explicit
profiles with their existing deployment obligations. Generation refuses unknown
or already-modified route shapes before writing. This command selects the matching
registry dependency unless the caller explicitly supplies a matching local source
(--privacy-source). Other blueprint adapters,
provider flows, persistent age permissions and deployed browser acceptance remain
separate work.
v13 removed this generator’s LMS target together with the complete Academy scaffold; the LMS example (the former LMS starter) has no school membership to bind.
The v13 fuzz inventory adds two isolated privacy targets for authenticated challenge transport and signed attestations. Deterministic fuzz-only keys permit mutation beyond signature validation; assertions bind accepted results to the current context, assurance and one-use replay contract. These targets enable no database or network integration. They expand v13’s required inventory to 42; the immutable v12 release line retains 40. Short diagnostics are not full release evidence, and the durable database lifecycle keeps its separate acceptance gates.
The v13 consent contract is independent of age assurance and concerns only
optional processing that the operator has assigned to an explicit purpose and
notice version. Absence, refusal, withdrawal, version mismatch and expiry deny
processing. An affirmative choice uses the exact observed revision; withdrawal
atomically advances that revision even when its form is stale, preventing an
earlier affirmative form from undoing it. The authenticated subject and tenant
are server-owned inputs, never browser identity fields. The explicit submission
also binds the displayed purpose/notice version to current server configuration,
so a policy change cannot borrow an old form’s unchanged revision. A bounded store must
serialize reads/updates against a persisted clock high-water mark and preserve
withdrawal tombstones; production rejects process-local state. The gate retries
an operation that lost the lock to a later second the same bounded way. Permission must
be checked immediately before each processing action, including deferred jobs.
Already-started external effects are not cancelled retroactively by a later
withdrawal, and consent does not authorize essential processing or establish
age/guardian authority. The optional consent-sqlite adapter initializes a new
local file explicitly; ordinary opening never creates or repairs missing state.
It serializes reads/updates with BEGIN IMMEDIATE, WAL/full synchronization,
a bounded private pool, immutable quota and persisted clock metadata. It retains
scope digests, latest choices/versions/revisions and timestamps, without raw
subject IDs. Those digests are pseudonymous data. Expired records and withdrawal
tombstones are never evicted to create capacity. All processes must use that same
trusted local file; multi-host replication is unsupported. Restoring stale state
requires quiesced processing, reconciled withdrawals and fresh purpose versions.
Deployment retention/restore review and hosted acceptance remain required
before release admission.
The owner-selected PostgreSQL consent increment passed combined source admission
in PR #239; final release admission remains separate. Its separate
consent-postgres feature exposes PostgresConsentStore::initialize for
deployment bootstrap and connect for ordinary runtime access. Both accept a connection string and immutable capacity.
One authoritative writable database owns the fixed rullst_consent schema.
Explicit initialization serializes concurrent bootstrap without resetting
existing state. A private bounded pool enforces verified TLS for remote TCP,
fixed name resolution, synchronous commits and bounded acquisition/SQL waits.
Every read/update validates permanent WAL-logged tables and locks the metadata
row, serializing clock observations, quota and revision changes across hosts.
Withdrawals remain durable tombstones; no expiry/capacity cleanup removes them.
Stored subjects remain domain-separated digests. Replication fencing, clock
synchronization, hardware durability and stale-backup reconciliation remain
deployment obligations. The facade feature privacy-consent-postgres must not
enable age assurance or add a database dependency to default builds. The
existing CLI privacy consumer remains explicitly SQLite until separately
extended; an ordinary typed application can use the PostgreSQL store directly.
Disposable-database concurrency, restricted-role, cancellation, clock, restart,
consumer and hosted acceptance are required before completion is claimed.
The opt-in make:privacy consumer mounts authenticated preferences,
an explicitly optional personalized greeting and an own-account JSON export
inside the starter’s existing CSRF/header/security boundary. It composes
with the age consumer while preserving both consumers’ dependency features.
Choice forms bind the displayed notice/version and current revision, with a
bounded expiring HMAC proof tied to the authenticated account, tenant and session;
an old tab cannot apply a choice after switching accounts or sessions. Identity
comes only from the authenticated user and the fixed application tenant.
The export projects only account ID, name and email with explicit output bounds;
it exposes no credential fields, produces no public artifact and does not mark
broader queued rights requests as fulfilled. Its direct private response needs no
retained export file and remains independent of optional-consent storage
availability. Refusal/withdrawal changes the next greeting to generic content.
Local materialized SaaS fixtures cover both age/privacy installation orders and
both dependency sources, real authenticated profile queries, explicit choices,
withdrawal/stale forms, session/account changes, CSRF, expiry, bounded input and missing or
failed state. A normal SaaS using PostgreSQL as its primary database compiles;
this is not live PostgreSQL consumer or browser/deployment acceptance. Bootstrap
explicitly initializes a new private consent file; ordinary opening never
recreates it. The generated consent profile remains shared-local SQLite only.
The separate SaaS triage assigns the examples’ reported defects to compatible v12.1 maintenance and v13 contracts; it is not fix evidence.
v13 source-train and adoption preparation
The development train uses one version, currently 13.0.0-alpha.4, for every
publishable package, and exact (=13.0.0-alpha.4) internal requirements, so
Cargo cannot pair packages from different prereleases. This prepares the CLI’s
actual major-version behavior; it is not a publication or stable-release claim.
The privacy package joins the candidate inventory with explicit optional facade
features; it was first published in the 13.0.0-alpha.1 prerelease on
October 11, 2026.
The rullst-upgrade-rules-v4 migration catalog recognizes source majors 12 and
13 and carries the v12 → v13 source rules (see the assisted upgrade contract);
v5/v6 applications, including their v11-era ecosystem crates, first upgrade
to v12 with the v12 CLI. It keeps
exact target-major CLI selection and rejects downgrades. Preparations from a
previous catalog require fresh preparation and verification. The 12.1-to-13 source inventory adds opt-in privacy APIs,
consumer generators and security header composition, and lists the changed
APIs and behavior that the catalog reports as must-change or review
findings. Automatic preparation remains limited to supported dependency
manifests and the existing compiler/check/test workflow; it must not invent code
rewrites, enable age or consent policies, bootstrap privacy state, replace
authentication or deploy an application. Source fixes stay with the developer,
guided by each finding’s migration row (section 12).
Migration evidence must distinguish small offline updater protocol fixtures
from generated SaaS consumers and the workspace examples compiled against actual framework source.
Release-time inventory and package checks must revisit this boundary as more
v13 changes land.
13.0 intentionally removes supported commands, features and providers, among
them make:omni, cargo rullst omni and the offline-sync feature (section
13); the Foundry, Nix, Buildah and gRPC generators and the Fly, Render and
Railway deploy targets; the self-updater’s binary replacement (section 12);
cargo rullst ai (section 11.3); the Capital providers of section 6.4 and the
Mail transports of the v13 Mail provider scope below; and the LMS, Blog,
Portfolio and ERP starters, now applications in examples/. The
migration guide lists every break; retain each in the
migration inventory.
v13 formal-verification pilot boundary
The Verus pilot has a locally verified candidate for production age-policy method decisions, with later evaluation of Auth authorization predicates and Capital integer money calculations. Specifications must remain linked to the executable implementation, with explicit trusted assumptions and external contracts. No dedicated public crate is proposed. A pinned, isolated verifier and manual workflow precede any required v13 check; compatibility, reproducible proofs, negative controls and measured CI cost are promotion criteria. This plan adds no v12.1 release gate or framework-wide correctness claim.
The first implementation candidate verifies the existing AgePolicy::permits
body with its actual enum variants and policy fields, extracted from Rust syntax.
No public API or production dependency changes are required. Extraction must
check the reviewed signatures/types and original structural equality derives,
copy the executable body unchanged and record source/type/body fingerprints.
Only verifier annotations, structural-equality support, a specification accessor
and an erased reveal step may be added to the projection. The proof covers all
nine risk/method combinations without narrowing valid runtime inputs; it does
not prove age evidence, policy validation, timestamps, storage or authorization.
The isolated verifier must reject weakened restricted/elevated policies and a
denied low-risk policy as negative controls. This is a bounded pilot candidate,
not a promotion into required release checks.
v13 shared passkey ceremony increment
The next Auth increment follows the shared ceremony contract.
Keep the existing synchronous process-local API compatible. Add a separate generic
shared manager in rullst-auth, backed first by opt-in authoritative PostgreSQL,
with tenant/account/session/RP binding, bounded expiry/quotas, atomic single use
and fail-closed outage/cancellation. Reuse private cryptographic validation; do
not expose a stateless acceptance bypass. Application credential ownership,
revocation and signature-counter CAS remain mandatory before session issuance.
Real independent-manager/database/process and HTTP/browser acceptance must precede
an implemented claim. No new crate or broad blueprint expansion is required.
v13 deployment acceptance increment
Before expanding deployment generators, exercise two independent application processes behind one digest-pinned existing reverse proxy. Keep readiness, unhealthy-node exclusion, bounded shutdown, forwarded identity and WebSocket behavior explicit. Ordinary HTTP admission must remain counted until the response body completes, errors or is dropped, including streaming data and trailers; returning response headers alone is not completion. This counts application body lifetime, not client receipt or TCP acknowledgement. Upgraded connections and background tasks require separate application-owned termination. Prove the body boundary locally before using it in deployment acceptance. No new gateway crate, automatic host administration or general availability guarantee is introduced.
v13 local deployment diagnostic
cargo rullst deploy:doctor is an offline, read-only command of the main CLI.
It inspects the selected bounded Rullst.toml snapshot (--config, or
documented defaults) and optionally one explicit literal environment file
(--env-file) or the three allowlisted process variables RULLST_ENV,
APP_ENV, APP_KEY (--process-env), against a --target of production
(the default) or staging. Never silently combine environment
sources or search parent dotenv files. Reuse Core environment resolution and
security validation; report obvious application-key mistakes separately from
unobserved key generation/custody. Config validity cannot prove mounted middleware,
webhook verification, proxy trust, shared state, TLS, backups or deployed behavior.
Emit deterministic text/JSON with fixed check codes, source categories, actionable messages and explicit uninspected controls. Do not print values, input paths, parser details or secret-derived hashes. Reject malformed, duplicate, oversized, linked/special-file and unsupported interpolated inputs without executing them. No subprocess, network access, configuration/global-environment mutation or automatic repair belongs in this command. Test the installed command with real generated configuration and negative inputs; retain full hosted acceptance.
v13 supervision observation extension
The owner explicitly requested the reusable proctoring base on September 20.
Extend rullst-supervision, not a second proctoring crate. Keep legacy visibility
APIs working, but new sessions must persist an exact selected collection set and
require matching acknowledgement at start/resume. Default selection remains
page visibility. New browser event kinds are focus, copy/cut/paste occurrence
and fullscreen transitions, never clipboard content or other-window inventories.
Optional camera/microphone/screen capture-status reports are distinct from
optional camera-presence/audio-activity adapter observations; preserve provenance
and inconclusive/unavailable outcomes without a misconduct score.
Add bounded, typed, statically dispatched analysis interfaces. Permission and active session/tenant/subject/revision must be checked before analysis and again before storing its result. Keep borrowed media samples bounded and out of durable event state/logs. No camera/audio model, identity inference, hidden recording, grading or automated accusation is introduced. Selected capabilities do not establish browser permission or prove media belongs to the learner; the host must bind capture to the authenticated session and implement adapter cancellation.
The SQLite candidate moved to schema v2 before its first publication, with explicit rejection of old schemas. Never silently rewrite a v1 database: preserve it for review and require a fresh independently named store/epoch and authority provisioning for this prepublication transition. Session pause/end/expiry and policy revisions must reject pending results. Retain all existing capacity, sequence, clock, reviewer authorization and retention invariants.
Expose new browser collection only by explicit application selection (the
removed make:supervision generator selected it by CLI option) and a matching
visible notice. The collector must bound its queue and stop on session control,
expiry or failure; it must not prevent copying, request fullscreen/capture or
start media analysis automatically. The crate’s SQLite tests cover local
adapter faults, disallowed capabilities, persistence, source attribution and
legacy visibility compatibility; the extension passed hosted admission in PR #226.
Conditional v13 supervision crate
The owner requested transparent learner/exam supervision and parental controls
as the first additional priority after the required v13 deliveries have been
implemented and validated. rullst-supervision is now an implementation
candidate, first published in the 13.0.0-alpha.1 prerelease, with bounded domain
contracts and shared-local SQLite state. Its
local generated-consumer and Chromium journeys passed before v13 removed that
generated LMS consumer (make:supervision). The baseline also passed
hosted checks and archive rehearsal in PR #222; the observation extension passed
PR #226 and its retrospective archive check. The combined source passed PR #239.
The package now enters the v13 release inventory with no default facade dependency.
It was first published in the 13.0.0-alpha.1 prerelease on October 11, 2026;
final release acceptance remains outstanding.
Keep supervision policy/session/event and reviewer-access contracts separate
from rullst-privacy age and consent primitives. Exam supervision and parental
controls need distinct modules and authorization policies. The initial candidate
had to deliver one real generated LMS journey (retired in v13 with the complete
Academy scaffold; applications now integrate the crate directly), visible session status, explicit
permissions, revocation, bounded collection/retention and actual application-side
enforcement, with cross-school/subject and unauthorized-reviewer negatives.
Parental time/content restrictions initially concern this application only.
The host must establish guardian/reviewer authority independently; age results,
account ownership, a checkbox or a claimed family relationship do not prove it.
Browser observations are untrusted client reports, never proof of misconduct, identity or an automatic reason to change grades or impose a penalty. Collect only the documented minimal events with visible active/paused/ended state; do not add covert camera/microphone/location capture or unrelated browsing data. Native device-wide controls, camera inference and managed operating-system agents need separate platform integration and acceptance. Global legal compliance is not inferred from these controls. The exact API/storage boundary and its threat model must be specified before scaffolding the crate. Empty contracts or a mock alone do not meet its admission criteria, and it must not delay required release gates; retain it for a subsequent v13 release if capacity is insufficient.
The initial implementation contract is now the
bounded supervision design. Use a separate package
with optional exam, parental, sqlite and analysis features, no Core/default dependency,
and a concrete SQLite adapter generic over a trusted clock. Tests use real local
SQLite and an injected deterministic clock; no memory mock is needed for this
initial backend. One private initialized database owns scoped authority,
sessions, parental enrollment/policy and allowlisted events. Serialize operations
with BEGIN IMMEDIATE, persist configuration, a clock high-water mark and a
global revision counter, and enforce bounded quotas and retention. One learner
has bounded retained-session and unexpired-event quotas inside the store-wide
limits, and rows already past retention never block admission. An opener
must supply the independently retained deployment epoch. This detects epoch
mismatch, not restoration of an old database with the same epoch.
v13 removed make:supervision together with the complete Academy LMS it
targeted. Applications depend on the crate directly with an exact version and
default features disabled; the removed generator remains in the git history.
Only trusted operator provisioning can establish expiring ExamReview or
ParentalManage authority after independent relationship verification. The host
binds current authenticated identity, school membership and resource access;
the store rechecks its own scoped grants atomically on each operation. Those
two authorization layers are not one transaction unless the host composes them
that way. In-flight results cannot be recalled after external membership changes.
Session start/resume requires explicit current policy/notice acknowledgement;
pause/end and policy changes use exact revisions. Events are only visible/hidden
page reports with an exact next client sequence and server receipt time. Their
absence or contents never influence grading. Parental enrollment is operator
managed: an enrolled subject with absent, expired or denied policy is denied;
an unenrolled subject retains existing application authorization. Revoking a
manager does not remove the subject’s restrictions. The integrating application
must enforce them on its original lesson/progress routes; the removed generated
opt-in did so for the Academy scaffold.
Versioned release-branch boundary
Release policy schema 4 binds major 13 to development main and major 12 to
maintenance v12. Schemas 2 and 3 retain their historical bindings; no existing
tag or receipt is reinterpreted. The former transitional v13 integration
branch (schema 3) was promoted to main and retired.
The source-admission gate requires a canonical matching tag, the exact
checked-out/tagged commit at the current protected branch head, and every
publishable inventory package at the tagged version before artifact builds.
All declared automatic release workflows accept main and v12; their
triggers still list the retired v13 ref.
Required manual/native/security evidence and the protected crates.io approval
remain separate mandatory gates. Archive evidence requires the named archive
job to succeed on the exact candidate; a green workflow with that job skipped
is insufficient. Incompatible manual package selectors must fail explicitly.
Observational CI reports may describe failed checks, but must stop on cancellation
so an obsolete run cannot retain the concurrency slot needed by its replacement.
Fuzz evidence must come from the candidate’s
release line and a source carrying that same policy; v12 results cannot be
credited to v13 merely because they are recent. The existing immutable v12
tags retain their original workflow and policy.
Stateless login cookie and session epoch (v13)
make_login_cookie issues a stateless rullst_session cookie: a v1.
AES-256-GCM token bound to APP_KEY that holds the user id and an expiry 30
days ahead. The server keeps no session list. make_logout_cookie only asks the
browser to delete its copy, so a copied cookie stays valid until it expires or
APP_KEY changes, which signs out every user, and a password change does not
end other sessions. v13 keeps the format, lifetime and validity of these
cookies.
Revocation is opt-in and additive. make_login_cookie_with_epoch(user_id, epoch, max_age) and encrypt_session_with_epoch issue v2. tokens whose
payload user_id|epoch|expires_at is authenticated with its own associated
data, so neither version can be relabelled as the other. max_age counts whole
seconds from 1 second to 400 days and sets both the token expiry and the cookie
Max-Age. decrypt_session_claims returns SessionClaims for v2. tokens and,
with epoch 0, for v1. tokens; tokens over 4096 bytes, unversioned, tampered,
wrongly keyed, malformed or expired tokens fail, and a token remains valid
through its expires_at second. SessionClaims::verify_epoch compares the
token’s epoch with the stored value in constant time and returns
AuthError::SessionRevoked on a mismatch. decrypt_session rejects v2.
tokens, so a path that still uses it fails closed instead of skipping the
epoch check. rullst-auth has no session middleware: the application stores
the epoch per user, checks it on every request and increments it atomically for
sign-out everywhere, a password change or reset, and account deactivation.
For an application that keeps its own users table, v13 adds single-use
password-reset token primitives. PasswordResetToken::generate draws 32 bytes
from the operating system’s CSPRNG and encodes them as 64 lowercase hex
characters (which the mail pipeline’s secret redaction leaves intact in a
token= link); Debug redacts it and its memory is zeroed on drop. Only
digest(), a domain-separated SHA-256 (rullst.auth.password-reset.v1) in
lowercase hex, is stored. password_reset_token_digest returns None for any
submitted value that is not 64 lowercase hex characters, so malformed input
never reaches a lookup, and password_reset_digests_match compares a stored
digest in constant time and never matches a stored value that is not a
64-character digest. Expiry, single use and one live token per account are
rules of the application’s store.
The cargo rullst auth and SaaS scaffolds (and the LMS example) store users.session_epoch
(BIGINT NOT NULL DEFAULT 0, excluded from ORM writes with
#[orm(db_default)] so a save cannot undo a bump), issue epoch cookies at sign-in and
registration, refuse a cookie with another epoch in auth_middleware, and add
POST /logout/everywhere, which increments the epoch in one UPDATE and clears
the browser cookie. Plain logout still clears only the current browser.
Revoking one device and listing sessions require server-side sessions
(SqlRecoveryStore).
The same scaffolds generate a password change and a password reset by email
(src/controllers/password_controller.rs, src/models/password_reset.rs and
a password_reset_tokens migration) and enable the mail feature. Every
submission is behind CSRF and the per-client credential_rate_limit, and every
new password follows the registration policy (12 characters to 72 bytes) and
must match its confirmation.
GET/POST /password/change, behindauth_middleware, verifies the current password with Argon2id on the bounded blocking pool (an account without a password hash never passes) and answers a wrong one with the generic “The current password is incorrect”.User::change_passwordstores the new hash and increments the epoch in oneUPDATEthat applies only while the account is still at the epoch of the asking cookie; that browser receives a cookie with the new epoch, so only it stays signed in, and the account’s reset token is deleted, so a link requested before the change no longer works. A cookie revoked meanwhile changes nothing and is sent to/login.POST /password/forgotanswers with the same page, after at least 400 ms, whether or not the email has an account. For an account it issues aPasswordResetTokenand stores only its digest with Unix-second expiry and creation times inpassword_reset_tokens, whose primary key isuser_id: one live token per account, replaced by a new request, except that a request less than 60 seconds after the previous one for the same account issues and sends nothing. Tokens expire afterRESET_TOKEN_LIFETIME_MINUTESofsrc/models/password_reset.rs(30 minutes as generated), which a compile-time assertion bounds to 10 minutes through 24 hours; the stored expiry, the reset email (“This link expires in 30 minutes.”, or “2 hours” for whole hours) and the forgot-password answer all state that one value (from 13.0.0-alpha.3; alpha.2 hard-coded “30 minutes” in the answer andAccountMailrefused more than 30). The link isRULLST_PUBLIC_ORIGIN/password/reset?token=…(process environment, then.env), validated byActionLinkand never derived from the request; when the origin is unset, development and test usehttp://localhost:3000, and staging and production log an error and send nothing.AccountMaildelivers it through theMailfacade in a background task, so delivery never delays the answer. In a debug build in development withoutMAIL_DRIVER, where the default log driver records only metadata, the link is also printed to standard output.GET /password/reset?token=…shows the form only for a live token, withCache-Control: no-storeandReferrer-Policy: no-referrer.POST /password/resetlooks the digest up before any Argon2 work, then, in one transaction, deletes the token only while it is still live, stores the new hash and increments the epoch; a malformed, unknown, expired, used or replaced token changes nothing. A completed reset signs nobody in: it redirects to/login?reset=done, which shows a notice.- A completed change or reset emails a “password changed” notice. Deleting an account leaves its reset token row, which no longer redeems.
- Account emails are written in the language of the request that caused them:
the
rullst::i18n::LocalethatLOCALES.layer()negotiated when the router has one, else theAccept-Languageheader, each matched by language to theMailLocaleset (English, Brazilian Portuguese, Spanish), else the editableDEFAULT_MAIL_LOCALE(English). The product name in their subject and heading isAPP_NAME(process environment, then.env) or the editableAPPLICATION_NAMEconstant, whose generated value is the package name.
cargo rullst make:oauth google|github|microsoft|apple (v13, from
13.0.0-alpha.3) adds sign-in with that provider to the same account model
without tower-sessions; each provider is one run, and the first creates the
shared flow, the oauth_identities table and its only migration.
GET /auth/<provider> and the callback sit behind CSRF and the password
login’s credential_rate_limit; the login challenge (state, PKCE verifier,
OIDC nonce, provider, same-site return path) is stored with
rullst_connect::challenge in rullst_connect_login_challenges of the
application database, keyed by a digest of an opaque 256-bit id that is the
only value in the rullst_oauth_challenge cookie (Path=/auth/<provider>,
ten minutes, HttpOnly, SameSite=Lax, Secure in staging and production
and on HTTPS). The callback consumes the challenge atomically before
validating state, PKCE and, for OpenID Connect, the ID token and its nonce. A
provider subject already in oauth_identities (provider and subject unique,
user id cascading) signs in. Otherwise only an email the provider verified is
used: an unknown one creates a password-less account linked to the subject,
and one that already belongs to an account (including one that signed in with
another provider) is refused unless the application switches
EXISTING_ACCOUNT_POLICY to LinkAndResetPassword, which links it, removes
the password and increments the session epoch in one transaction. Subjects and
verified emails are: Google, the ID token sub and email_verified; GitHub
(OAuth 2.0), the numeric account id and the primary address of
GET /user/emails marked verified; Microsoft (MicrosoftOidcProvider with
the required MICROSOFT_TENANT of common, organizations, consumers or a
directory ID), <tid>:<oid> and the email claim only with xms_edov: true;
Apple, the ID token sub and email_verified. Apple answers with a
cross-site form POST, so its challenge cookie is SameSite=None; Secure, its
callback also accepts POST and is listed in csrf_signed_webhook_paths (the
challenge’s state replaces the CSRF token), its client secret is a five-minute
ES256 JWT signed with the .p8 key from APPLE_PRIVATE_KEY (checked at
startup, never written to a file), and the unsigned first-login name only
names a new account. Sign-in uses make_login_cookie_with_epoch with the
account’s current epoch, refusing an account the password login refuses.
Unset credentials turn a provider off; an incomplete or mock configuration
without the oauth-mock application feature, mock credentials in staging or
production, and a missing callback origin there stop startup.
v13 active-session management (source admitted, prerelease)
Extend the existing opt-in SqlRecoveryStore rather than introducing another
authentication registry or crate. A currently valid opaque session may list
only its account’s at-most-20 active sessions, revoke a selected sibling or
revoke all siblings. Management identifiers are purpose-separated keyed
digests, never bearer credentials. Current-session logout keeps the existing
revocation API. Every protected request still verifies authoritative SQL state;
encrypted-only cookies and independent JWTs do not acquire revocation implicitly.
Inventory may expose creation/expiry times, a current-session marker and an explicit bounded display label. Do not collect IP addresses, raw user agents, device fingerprints or activity history. Old sessions without the additive metadata keep working and report unknown creation/label values. Metadata follows expired-session retention, logout and password-reset deletion in the same transactions. An operator-owned task purges at most 100 expired sessions per transaction; time passing alone does not physically delete rows. Session operations have a ten-second database deadline; an error does not prove an uncertain write rolled back, and applications reconcile authoritative state before reporting success. Management requires current authentication inside its SQL transaction; selective revocation binds the target to that account and rejects the current session. Logout of siblings also advances the account session version while preserving the current session, fencing previously issued password-authentication proofs.
The application supplies trusted server time and mounts management behind its authenticated tenant selection, CSRF, security headers, WAF and ingress limits. Account stores remain explicitly separated by application/tenant database and keys as documented in the recovery contract; request data cannot choose either. No device label proves device identity. Already-authorized in-flight operations, WebSockets and sessions managed by another identity system need separate policy. Required acceptance includes SQLite and PostgreSQL transactions, cross-account and cross-store denial, independent-process request verification, restart, expiry, password recovery, concurrent revocation and fail-closed storage errors. Source/package admission passed in PR #236; final release admission remains separate.
v13 email-login implementation contract
The owner-selected email-login increment passed combined hosted source/package admission in PR #239; final release admission remains separate. See the operational contract. It must stay explicitly optional in Auth, with SQLite shared-local and PostgreSQL shared state profiles composed with the existing authoritative recovery account/session registry. It must not turn a password-reset token into a login credential or silently enable email login for every existing account.
The selected journey requires an explicit server-configured application/tenant namespace and account opt-in established through existing authenticated account proof plus the host’s tenant/MFA policy. The application resolves membership and ordinary authorization; namespace strings from URLs are never authority. Email access alone does not satisfy a stronger authentication requirement.
Issuance generates separate random 256-bit emailed and browser-held secrets, binds their digests to the account, namespace and current authentication epoch, and queues a minimized encrypted notice atomically. Known, unknown, disabled and throttled accounts receive the same public acknowledgement. Storage, account and request limits must remain bounded. Tokens expire after a fixed 15-minute lifetime and are invalidated by replacement, account-policy changes or a changed authentication epoch. Database time observations and post-storage expiry checks must prevent rollback or long lock waits from authenticating stale evidence.
GET/HEAD requests may render the landing page but never consume a token or mint a session. Redemption requires deliberate CSRF-protected POST, the matching browser-held secret and the current configured context. Token consumption and creation of the existing revocable opaque session must commit in one database transaction. Any uncertain outcome returns no authentication credential. The session retains existing limits, inventory, logout and epoch revocation.
Landing and destination URLs are explicit server configuration, with HTTPS in production, no request-derived host/redirect and no third-party page content. Responses require no-store and no-referrer protections; secret-bearing URLs, cookies and messages must not enter logs. A fenced encrypted delivery outbox supports bounded retries and stable delivery identifiers, with a dedicated Mail security template and deterministic delivery fixtures. An email scanner’s GET must not invalidate the user’s login. These token and prefetch choices draw on OWASP’s URL-token guidance and Supabase’s documented prefetch behavior.
Required acceptance includes both relational backends, concurrent redemption, account/namespace/browser mismatch, account-policy and password-reset races, outbox failure/retry, session limits, server restart, a real HTTP/browser journey and extracted-package consumers. Production credentials and live inbox delivery remain outside the owner’s authorized testing scope. The existing reset journey keeps its separate behavior and must retain its regression evidence.
v13 application API-token implementation contract
The third owner-selected increment is a separate optional Auth lifecycle for opaque application API tokens, with SQLite shared-local and PostgreSQL shared state. It must compose the authoritative recovery account epoch and private database connection safeguards without enabling email-login, JWT or OAuth by default. Provider credentials and browser-session tokens are separate purposes.
Issuance requires a current password-authentication proof from the same account registry plus the host’s tenant/MFA/permission approval. An immutable server namespace and literal scope allowlist bound every token. Requested scopes are nonempty, bounded and exact (no wildcards); the application intersects its current account permissions before issuance and checks current tenant/resource authorization on every domain request. A client-supplied namespace or subject never establishes authority. API tokens cannot mint other tokens through this management API.
Return a random 256-bit secret once, with a separate opaque management ID and an unmistakable versioned prefix; persist only a purpose/namespace-bound HMAC. Store bounded labels, literal scopes, expiry and revision, without IP, user-agent or usage history. Enforce a per-namespace row quota, at most 20 active tokens per account, and a configured maximum lifetime no greater than 30 days. Verification must read authoritative state, validate the account epoch, exact required scope subset and trusted time, and fail on missing/unavailable/revoked state. Never cache a positive verification as a replacement for these checks.
Owner-only inventory, idempotent revocation and atomic compare-and-swap rotation are required. Rotation keeps the management ID and scopes, advances the revision, replaces the secret and may renew only within the configured lifetime. A stale rotation cannot overwrite a newer credential or revive a revoked token. Deleting a token is authoritative revocation: caller-selected IDs cannot recreate it. Account epoch changes invalidate all older tokens. Concurrent checks have a documented database linearization point; already authorized external work cannot be recalled or made atomic with a later revocation.
Reuse verified remote TLS, bounded private pools/SQL deadlines, durable storage checks, namespace configuration binding and persisted clock observations. An uncertain issuance/rotation returns no credential, and restart, expiry during lock waits, rollback, foreign-account management, scope escalation, token-purpose confusion and concurrent rotation/revocation need executable evidence. Document backup anti-rollback and host-owned authorization boundaries. Full workspace, native protocol/process, authenticated HTTP and extracted-package admission are required before a supported feature claim; no external provider accounts are needed or authorized.
HTTP composition adds an explicit Core MachineEndpoint::verified_bearer
constructor using the existing exact-route MachineRequestVerifier contract.
Auth supplies a verifier that requires one Authorization bearer header, reads
current token state and inserts a redacted principal. It never accepts cookies,
origin-bearing browser requests or URL tokens as machine authentication. The
existing static bearer constructor retains its behavior; only an authenticated
exact method/path receives the machine CSRF exception, with WAF/headers retained.
v13 shared PostgreSQL mail-suppression implementation contract
The fourth owner-selected increment extends Mail’s existing suppression traits
with an optional PostgreSQL store and facade feature. It must preserve verified
event replay binding, conflict rejection, monotonic reason precedence, immutable
recipient/event quotas and final pre-dispatch checks through SuppressionGuard.
It is not a new transport and must not change default Mail/SQLite features.
Each instance selects an immutable server-owned namespace, quotas and a strong secret HMAC key. Persist purpose/namespace-bound keyed recipient/event identifiers and event fingerprints, not raw recipient addresses, provider event IDs, message bodies or delivery history. Keep only the authoritative reason/provider and first/last observation times needed by the existing record contract. Configuration and key drift fail closed. A namespace chosen in a request is never authority; the host authenticates tenant membership and verified provider events before selecting the corresponding store/driver. Memory remains the explicit offline test implementation; production database failures cannot silently use it.
Provide explicit deployment initialization and ordinary startup without DDL or missing-state repair. Runtime operates without schema mutation privileges, on one authoritative writable PostgreSQL database with verified remote TLS, permanent tables, durable commit settings, bounded pools/waits and whole-operation deadlines. Serialize event application, lookup and retention through the namespace control row. Atomically record replay evidence and recipient state; cancellation, conflicting events or quota failures must not leave partial state. Recheck persisted server time and storage durability before reporting outcomes. The persisted time is a monotonic high-water mark: a host clock at most 300 seconds behind it is cross-host skew and adopts the recorded time; a larger regression fails closed.
Retention may prune replay identifiers after the host-selected provider replay window, but never remove recipient suppression as a side effect. Lookup/storage faults block delivery. Check immediately before dispatch, while documenting that a suppression committed after that check cannot recall in-flight external mail. This does not promise exactly-once provider delivery, inbox acceptance, backup anti-rollback, automatic failover, legal compliance or automatic opt-in after a complaint. Required evidence includes independent pools/processes, concurrent replay/conflicts/quotas, rollback/cancellation, restricted roles, service restart, real guard/worker composition and extracted-package consumers.
v13 durable recurring-publication implementation contract
The fifth owner-selected increment belongs to the existing Messaging crate as
optional schedules-postgres, with an explicit facade feature. It coordinates
bounded UTC cron occurrences across instances through one authoritative PostgreSQL
database and relays them to the existing static MessageBroker contract. Core’s
process-local Scheduler retains its API; it evaluates POSIX five-field
expressions in UTC (weekday 0/7=Sun, either restricted day field matches), or
in a named IANA zone through the opt-in scheduler-tz task_in_tz (section
4.3), unlike the projection below. A broker publication is
not proof of handler completion or an exactly-once external effect.
Require a server-owned namespace, immutable limits and explicit encrypted storage keys. Schedule names are bounded application configuration, not tenant authority. The host authenticates authoring/inspection/cancellation and selects the correct namespace and broker. Creation is idempotent for the same immutable definition; conflicting reuse fails. An edit requires cancellation and a new schedule name, with immutable generation identities protecting retained occurrence keys.
Use the cron crate’s five-field projection in UTC (weekday 1=Sun through 7=Sat, calendar-day and weekday restrictions intersect; names are accepted), with a one-minute minimum resolution, bounded definitions/payloads, explicit first-after time and explicit missed-run policy. Bounded catch-up emits the oldest due occurrences within the caller’s tick budget; coalescing emits one oldest outstanding occurrence and advances after the observed current time. Advancing the schedule and persisting frozen occurrence content must be atomic. Storage quota exhaustion cannot silently discard due occurrences.
Persist definitions and occurrence content using authenticated encryption bound to namespace, schedule generation and occurrence identity. Do not silently adopt plaintext storage. Lease ownership, attempts, retry deadlines and terminal state are authoritative SQL data. Leases are unpredictable and exactly fenced; stale workers cannot acknowledge/retry/revive a cancelled or superseded occurrence. Broker requests use deterministic purpose-separated occurrence idempotency keys, and retries preserve exact content. Recheck a live lease immediately before publication and acknowledge only after successful broker acceptance. If acceptance precedes an uncertain ACK, retain evidence of acceptance and reconcile/retry the same key. A cancellation after the final check cannot recall an in-flight broker request; consumers must deduplicate/authorize at their side-effect boundary.
Expose bounded inventory/terminal inspection, explicit failed-occurrence retry, permanent cancellation and retention of terminal rows only. Automatic attempts and the delivery window are bounded. Deadlines, clock rollback beyond a five-second cross-instance skew tolerance (a trailing instance adopts the monotonic recorded namespace time), namespace/key drift and non-durable/unavailable state fail closed. Reuse verified TLS, bounded pools/SQL deadlines, explicit initialization and runtime without DDL privileges. Document UTC-only behavior, unchanged per-process cron, broker deduplication retention, backup/failover obligations and host-owned authorization. Required acceptance includes independent instances/processes, real PostgreSQL rollback, restart and restricted roles, cancellation/lease races, durable broker consumption, publication-before-ACK replay and extracted-package/facade consumers.
v13 durable outgoing-webhook implementation contract
The sixth owner-selected increment extends Messaging behind opt-in webhooks
and facade messaging-webhooks. Compose the existing encrypted SQLite broker
as a private durable outbox rather than adding a second queue. The first profile
supports independent processes sharing one local database file; remote/multi-host
webhook state remains separate. Do not change default dependencies. App-domain
SQL transactions and this outbox are distinct; applications needing atomic
domain publication must bridge from their existing transactional outbox.
One server-approved immutable destination, signing identity, delivery window and bounded policy belong to each server-owned namespace. Persist an encrypted control publication with a fixed idempotency key: configuration/secret drift must conflict, not redirect queued data. Never expose this private broker or purge/subscribe to its control topic. Freeze bounded JSON bytes and event kind; retries keep the stable message ID and exact body. The host authorizes publishing, inspection, cancellation and manual retry before selecting the namespace.
Production accepts only explicitly configured HTTPS destinations, with verified TLS, no proxies/redirects, bounded timeouts and fresh public-address resolution pinned to the actual connection. Deny private, loopback, link-local, multicast, reserved, documentation and transition address ranges, including mixed DNS answers. An explicit literal-loopback development mode and deterministic offline mode may support owned fixtures; production configuration rejects both. Empty or mock signing credentials select offline delivery, never a network fallback.
Sign exact body bytes, stable ID, kind, key ID and attempt timestamp with a purpose-separated HMAC-SHA256 contract. Provide strict constant-time receiver verification with bounded time skew; receivers must also store processed IDs and authorize the event. Do not claim signatures prevent replay by themselves. Keep signing/storage keys separate and redact credentials, URL, payload and response bodies from Debug/errors/terminal inspection.
Use bounded attempts/backoff and an immutable delivery window. Revalidate the actual SQL lease immediately before external dispatch, after DNS work. A late worker cannot acknowledge/retry/revive another worker’s claim or cancelled work. Acceptance before an uncertain ACK remains an at-least-once condition; preserve the accepted HTTP status/ID and retry the same identity. Retry transient failures, terminally reject redirects and permanent failures, and expose bounded minimized terminal inspection, explicit retry within the original window, cancellation and terminal retention. Retention must not delete active work or control state. The host owns disciplined trusted time, persistent disk/backup policy, receiver deduplication, endpoint approval and worker supervision. Native acceptance must include actual HTTP/TLS protocol fixtures, SSRF/redirect/signature/replay negatives, independent instances/process restart, SQL cancellation/outage/lease contention, retry/dead-letter/retention and extracted-package/facade consumers. Real provider accounts are not required or implied.
v13 SQLite messaging lock-time correction
Before composing outgoing webhooks, real lock-contention tests demonstrated
that SQLite broker operations sampled time before BEGIN IMMEDIATE: an ACK
could be accepted after its lease expired while waiting, and a new claim could
already be expired on acquisition. Publication, claim, ACK, retry and dead-letter
operations must sample trusted time after acquiring their write transaction.
Retry availability and new lease duration start from that admitted instant.
Expired workers fail with LeaseExpired; their work remains recoverable. The
process-local InMemoryBroker (also the Redis mock backend) likewise samples
time after acquiring its state lock. This
correction preserves public API/schema and does not add persistent clock
anti-rollback or change at-least-once delivery semantics.
v13 shared-local SQLite bootstrap connections
The Labs, Media and Supervision stores and the Privacy consent and age-replay
SQLite stores bound every wait for a pooled connection at 5 seconds, while
statements and commits wait for the disk. Establishing a connection (opening
the file and its WAL, and in initialize the WAL conversion with its full
syncs) runs inside that bound. Loaded hosted Windows runners stall a process’s
file I/O for 5 to 12 seconds at times, so initialize failed there with
Storage or Busy and left a new file that a second initialize refuses.
Since 13.0.0-alpha.3, the steps of initialize and open (for the age-replay
store, open, which also creates a missing file) are tried again when their
connection times out, three attempts in total (about 15 seconds): no other task
can use the new pool yet, so the timeout means a slow establishment, not
contention. Another connection or process that keeps the file locked also
delays establishment: SQLite’s busy timeout for that lock is also 5 seconds,
and on hosted Windows runners it ended about half a second after the pool wait,
so the step is tried again. A Windows-only test per store holds the file under
SQLite’s exclusive lock while open connects, releases it after the retry and
checks that open succeeds. On Linux the busy timeout ends only milliseconds
after the pool wait, so the same lock can instead end open with SQLite’s busy
result, which is not retried. Every other operation keeps the single 5-second
bound and its Busy or Storage result. Labs keeps the SQLite result code,
I/O error kind or pool condition behind the last Storage error of a thread
for its test fixtures, never a message, SQL text or path.
v12 audit correction invariants
12.1 account mail: rullst-mail::ActionLink validates an exact
application-configured origin (HTTPS, or explicit loopback HTTP); action URLs
and Message contents are redacted from Debug. The compatible Message
pipeline preserves only bounded opaque token query values inside safe body
URLs, including HTML-escaped query separators. This context-aware exception
does not apply to subjects, arbitrary non-URL text, URL credentials, other
secret parameters, fragments, provider errors or telemetry. Applications own
the destination authorization; a generic body URL is not an identity proof.
AccountMail builds versioned deterministic English, Portuguese and Spanish
account notices. A link’s remaining lifetime is stated as 1–1440 minutes (24
hours) for PasswordReset and 1–30 minutes for the verification and
email-change links, in minutes or, when whole, hours (“1 hour”, “24 hours”);
anything else is a validation error. Reset issuance expires after 20 minutes; delivery uses an
absolute UTC expiry so a stable idempotency key retains identical content.
Templates generate neither action tokens nor marketing consent and add no
tracking. Provider tracking configuration remains a separate obligation.
LogDriver records only delivery metadata.
Opt-in Auth recovery-postgres/recovery-sqlite owns an authoritative account
registry, purpose-separated keyed token digests, revocable opaque sessions and
an AES-GCM transactional outbox. Account creation enqueues welcome; password
replacement, reset consumption, sibling invalidation, session revocation and a
password-change notice commit atomically. The facade account-mail-* features
compose this store with Mail without another publishable crate. Existing
password tables and encrypted-only cookies require explicit migration; no
implicit application rewrite or cross-database transaction is promised.
Bounded claims, retry, idempotency propagation and Resend/Svix feedback
verification are additive foundations; the 12.1 ACS Managed Identity transport
was removed in 13.0. Redis recovery,
other identity-action transactions, marketing consent and audited administrative
resend are not implemented by this contract. See account mail
for exact limits, deployment obligations and external acceptance boundaries.
12.1 additional Mail transports: native SendPulse static Bearer authentication
preserves the mandatory pipeline and deterministic offline credentials. Real
sends require an explicit verified sender, due message, bounded JSON and
validated success body. SendPulse requires account-level tracking configuration
and rejects inline CID/unsubscribe-header messages outside its reviewed REST
contract. The local MailTrap harness is not the hosted Mailtrap service.
Provider account acceptance and durable operation reconciliation remain
external; no exactly-once promise is inferred from a delivery ID. HTTP failures
omit provider bodies while retaining status and bounded retry metadata. The
Mailjet and Mailtrap transports added in 12.1 were removed in 13.0.
v13 Mail provider scope: rullst-mail keeps Resend, AWS SES (native SigV4
behind aws-ses, or the offline fixture and explicit bearer proxy), SendPulse
and SMTP, the log, memory and offline mock_* drivers, and the shared
pipeline, failover, tenant resolver, attachment inspection, suppression,
observation, feedback and queue support. The SendGrid, Postmark, Mailjet,
Mailtrap and Azure Communication Services transports, their MAIL_DRIVER
values and the settings only they read were removed; the code remains in Git
history and the 12.x line. A MAIL_DRIVER or [mail] driver naming a removed
transport must fail facade resolution with MailError::ConfigError naming the
driver and the migration row, and must never fall back to log, memory or
an offline mock. Queued rullst_mail_send jobs carry no provider name, so a
13 worker delivers 12.x jobs through its configured driver or fails them
visibly. Suppression rows and observations keep free-form provider labels and
stay readable. Applications implement other providers with MailDriver
(Writing your own mail transport); the
migration guide lists the removed names, and
cargo rullst upgrade reports them as V13-MAIL-REMOVED.
12.1 one-time Stripe foundation: a separate fixed-price mode=payment
contract checks active provider catalog amount/currency before creation and
binds session, owner, customer, attempt, price and mode. Receipt reads validate
the expanded PaymentIntent/charge, with partial refunds and disputes preventing
Paid. Signed event hints must be reconciled through application-owned durable
inbox/entitlement transactions. Existing generated billing remains recurring;
new sandbox evidence and merchant refund/dispute operations are still required.
12.1 authenticated machine endpoints: an immutable MachineEndpointPolicy
on Server exempts only registered write-method/path pairs from browser CSRF after
proving a strong bearer secret or an explicit host-supplied signed-webhook/mTLS
verifier. Cookie/Origin/Sec-Fetch-Site inputs are rejected on these routes; body
size is bounded and WAF/secure headers remain composed. The development
hot-reload server (Server::new_hot) authenticates the same routes in front of
each reloaded router. A path segment after the first may be a {name} placeholder
matching one non-empty segment other than . or .. (v13); two routes with the
same method that one request path could match are refused. Wildcards and weak secrets fail at construction; a static bearer token needs 32–200 printable
ASCII bytes with at least 8 distinct values. Applications own ingress limits, replay storage,
certificate trust and authorization; an unverified proxy header is not mTLS.
The current release audit reopens earlier readiness claims. A historical score, checked roadmap item or green mainline run is not evidence that the current revision satisfies these contracts.
- Core’s production COEP remains
require-corpby default. Application configuration accepts onlyrequire-corp,credentialless, orunsafe-none; choosing a less isolated policy and the matching CSP/media allowlist is an explicit application threat-model decision. Thehtml!parser accepts and strips source-only<!-- ... -->comments; comments do not create a raw-HTML trust boundary or client-visible output. - Generated safe ORM projections validate column identifiers. Empty membership
predicates match nothing. Mandatory tenant, global and soft-delete scopes
remain grouped outside application
ORexpressions; nested queries preserve validation errors. Bulk mutation must not bypass model authorization. - Queries inside a managed transaction use its executor. Callbacks that already borrow a mutation executor must fail explicitly on unsupported reentrant ORM access rather than deadlock or silently use an unrelated connection.
- Local durable stores use SQLx-owned transactions so dropping a cancelled operation schedules rollback. A cancellation racing an already dispatched commit can still have an uncertain outcome and requires reconciliation.
- Authentication validates expiry independently of clock-skew allowances; provider-specific identity claims, nonce and refresh semantics cannot be replaced by successful offline fixtures.
- Payment adapters must not fabricate live checkout prices, portal URLs or mutation success. Unsupported provider operations fail explicitly. Signed webhook bytes still require provider-specific schema and lifecycle validation.
- Development restart owns builds, application snapshots and migrations. It is not a stable Rust DLL ABI or production rolling-deployment mechanism. Windows descendant cleanup remains best effort without a Job Object implementation.
These describe required behavior, not a declaration that every final release gate has passed. The audit records the current evidence and remaining work.
Application-key maintenance invariant (12.1.1 and v13)
Application-key validation rejects public scaffold/documentation placeholders
after trimming and case normalization. Legacy Rullst.toml discovery accepts
only exact app_key or key field names, not fields sharing a prefix. It keeps
the historical value before the next = so valid existing sessions remain
readable; it does not rewrite configuration or silently rotate secrets.
Prefer APP_KEY for new values containing =. Moving an existing TOML value
must preserve its effective bytes or explicitly rotate the secret and invalidate
sessions. Session operations and optional JWT signing-key construction reject
the documented placeholders before accepting session/token processing.
The maintenance correction is carried from v12 PR #259 and was published in
12.1.1; v13 release admission is separate from its presence in development
source.
The corresponding facade fuzz harnesses exercise real configuration parsing, authenticated session decryption/tamper rejection, tenant membership resolution and tenant-scoped realtime publication, with deterministic positive/negative controls. The realtime target does not exercise TCP/WebSocket frame decoding. Historical runs of the replaced no-op/weak-fixture harnesses are not evidence for these contracts. v13 campaign admission remains outstanding; the port does not import the v12-specific source-equivalence policy.
Studio browser composition invariant (12.1.0)
Studio browser composition in the published 12.1.0 maintenance release preserves
both root and /studio-nested same-origin asset routes. A raw browser without
a supplied cache renders an explicit unavailable state and exposes no cache
mutation endpoints. The full local builder installs the configured cache once
and retains its verified-loopback/same-origin protection. Assets and navigation
fixes do not constitute a shared-production authentication mode; existing
application-level workaround routes must be removed before upgrading.
Mobile presentation invariant (12.1.0, carried forward to v13)
The published 12.1.0 mobile maintenance contract keeps Nexus navigation
dismissible by close control, backdrop, Escape and links, with keyboard focus
containment/return and visible no-JavaScript navigation. The Portfolio example
(examples/portfolio, the former scaffold) must reflow at phone widths and wrap long content instead of hiding overflow.
These are presentation fixes, not changes to server authorization; pre-existing
generated source and showcase HTML-rewriting workarounds need explicit migration.
Pre-release scaffold source invariant
An unpublished pre-release cargo-rullst may reuse only local framework crates
whose package names and versions exactly match that CLI. The invocation directory
is preferred; otherwise the exact still-present checkout from which the CLI was
compiled is used. Stable or version-mismatched packages fall back to crates.io.
This permits evaluation outside the repository without silently mixing release
trains, but generated absolute path dependencies remain non-portable until the
matching immutable release is published.
Distributed operation tracing candidate (v13, source admitted)
The fifth approved September increment extends Core/facade’s optional
telemetry capability, using the maintained OpenTelemetry SDK and OTLP/HTTP
protobuf transport. The existing process-local SpanCollector and authenticated
Studio ingestion remain separate interfaces. A new explicit configuration must
select service and bounded operation labels, a trusted collector, bounded queue,
batch and export deadlines, and an owned flush/shutdown lifecycle. It must not
infer user identity or authorization from trace identifiers.
Context propagation accepts canonical nonzero W3C version-00 traceparent only
after an explicit upstream trust decision. Untrusted ingress starts a new trace;
arbitrary baggage and vendor tracestate are not propagated by this minimized
profile. An actual independent producer/consumer journey must preserve parent
relationships through the existing Messaging carrier and reach a disposable
standard collector. Export only bounded approved operation/service names,
trace/span relationships, timing, kind and status; omit application attributes,
events, error descriptions, SQL, tokens, bodies and ambient resource metadata.
This profile does not sanitize other tracing/logging layers.
Export must run outside request execution with a compatible SDK processor/client, bounded response handling and no redirects or ambient proxies. Production uses verified HTTPS; literal-loopback HTTP is an explicit test/local-collector choice. Remote failure never falls back to a fake success. Malformed propagation, queue pressure, unavailable/slow collectors, TLS denial, exact package consumption and flush/shutdown require executable evidence. Sampling, retention, collector access, durability, availability and deployment operations remain host responsibilities; trace delivery is best effort, not an audit ledger or application transaction. The implementation and actual collector/TLS/process evidence are documented in the tracing guide. Hosted source/package admission passed in PR #236; final release admission remains separate. Legacy initialization uses compatible threaded export and the corrected OTLP/HTTP endpoint; it retains its separate general logging/metadata policy.
Recoverable Live UI candidate (v13, source admitted)
The fourth approved September increment extends rullst-core::live with a
separate opt-in typed recovery API, preserving the legacy component interface.
An application-bound tenant/account/component scope and mandatory static-dispatch
authorization callback must be checked before upgrading, before actions and
outputs, and periodically while connected. Exact browser origin and protocol
validation supplement, rather than replace, application authentication.
The v1 transport sends bounded complete server snapshots on initial connection, reconnection and conflicts. Commands carry a bounded correlation ID and expected revision; the host must check that revision atomically with its persistent domain write. The client must not queue or automatically replay mutations after uncertain disconnects. Recovery converges to authoritative application state, not replay of every missed transient event. Browser/server buffers, admitted connections, callbacks, sends, session lifetime and event counts must be bounded. Expired or revoked access closes the session without exposing a new snapshot.
A small optional same-origin browser module and real browser/server tests must
prove reconnect convergence, action conflict, session/tenant denial, revocation,
bounded malformed/slow peers and recovery after an application restart. Domain
persistence, transactional revision checks, HTML escaping, authorization policy,
replication and proxy configuration remain explicit application responsibilities.
No automatic durability or upgrade of legacy LiveComponent code is implied.
The legacy live_ws_handler bounds incoming WebSocket frames and messages to
64 KiB before JSON parsing; its origin, admission and idle policy stay with the
application route. ws::WebSocket::recv skips Ping/Pong control frames, so
client or proxy keepalives do not end a legacy Live session. A
#[live_component] dispatches only on the first present rullst_event,
action or event string field and runs at most one #[live_event] handler
per message; other payload keys and values, including form inputs, never
select a handler. A path-qualified marker such as #[rullst::live_event] marks
a handler exactly like the bare attribute.
The implementation and its local protocol/Chromium acceptance are recorded in
the recovery guide. Hosted workspace/platform/package
admission passed in PR #236; final release admission remains separate.
The API uses complete snapshots; it does not imply
DOM diffing, broadcast of other clients’ changes or automatic event replay.
Remote messaging candidate (v13, source admitted)
The first remote adapter is an opt-in Redis Streams profile in the existing
rullst-messaging crate. Redis stores the canonical envelope log; Rullst owns
bounded group, retry, dead-letter and single-use lease indexes. These are Rullst
consumer groups, not interoperability with arbitrary native XREADGROUP clients.
Every lease mutation must check authoritative server time and the exact current
capability atomically. Publication replay must retain the original receipt until
explicit terminal purge. Empty/mock credentials select an explicit process-local
fixture; a failed live connection must never downgrade to that fixture.
The initial profile targets a dedicated standalone Redis 7.4+ database with verified TLS outside an explicit literal-loopback test mode. Exact configuration and an application-supplied deployment generation bind persisted namespaces; provisioning is explicit and normal connection must not recreate lost state. The TLS adapter preserves an installed Rustls process provider and otherwise installs aws-lc-rs, the framework’s Rustls provider, before Redis creates its client, avoiding implicit-provider panics when dependency features enable multiple providers. Applications requiring a different provider install it before broker startup; trust and hostname checks remain mandatory. Lua mutations are isolated but do not roll back on runtime errors: a persistent in-progress marker must quarantine partial changes rather than silently continue. Bounded batches, retained bytes, subscriptions, operation deadlines and admission limits are required. AOF/no-eviction, clock discipline, credentials/ACLs, backups, restore reconciliation, topic authorization and destination idempotency remain deployment responsibilities. Cluster, replication/failover, native-group mixing, automatic repair and exactly-once effects are excluded from this profile.
Admission requires the shared broker contract plus an actual disposable Redis restart, conflicting publication, concurrent consumer, expired/late ACK, retry, DLQ, outbox replay, configuration-loss and partial-write failure evidence. The implementation candidate and its capability/operational limits are described in the Redis messaging guide. Hosted source/package admission passed in PR #236; final release admission remains separate.
OAuth authorization request invariant
The built-in Connect authorization-code providers explicitly request
response_type=code. With ordinary authorization endpoints, the plain URL,
state, PKCE, and combined trait variants each contain exactly one response
type and retain the configured client, redirect, scopes and encoded state/PKCE.
Apple retains response_mode=form_post; offline mock redirects remain local
fixtures rather than real authorization requests. The managed AuthSession
extractor reads only the callback query string, so it does not complete Apple’s
cross-site POST callback; an Apple host keeps its state, PKCE verifier and
nonce behind a SameSite=None; Secure challenge cookie and validates the
posted form itself. The v13 AuthSessionForm extractor consumes the
same managed challenge from a POST application/x-www-form-urlencoded body of
at most 16 KiB, rejecting another method, content type or oversized body before
touching the challenge; the SameSite=None; Secure cookie requirement remains. Application configuration,
nonce/callback validation and provider-account interoperability remain separate.
The generic build_oauth_params helper and exported redirect macro remain
response-type-neutral for downstream custom providers. Callers own optional
endpoint query data and must not supply conflicting reserved parameters.
Client-supplied token invariant
Provider::get_user_from_token is a profile lookup for a token the server
obtained for its own client. It is not an authentication primitive for a token
supplied by a native or mobile client: except for Apple, the adapters ask the
provider’s userinfo/profile endpoint about the bearer token, which does not
prove that the token was issued to the configured client_id. Sign-in must use
the application’s own authorization-code exchange or an ID token verified for
this client’s issuer, audience, expiry, signature and nonce. Apple’s adapter
validates an ID token’s audience there, but checks no nonce. In v13,
GoogleProvider::verify_id_token and
OidcProvider::verify_id_token are that audience-bound entry point: they
require a non-empty expected nonce, reuse the code-exchange ID-token validation
(JWKS signature, exact issuer, aud/azp equal to the client ID, except that
Google’s verify_id_token also accepts an azp from its explicit bounded
native-presenter list while aud stays exact, exp/iat,
nonce) and never call userinfo.
Shared-local facade composition invariant
The umbrella features auth-sqlite, capital-quota-sql, oauth-sqlite,
mail-sqlite, messaging-sqlite, and queue-sqlite may deliberately share one
file-backed SQLite database for a bounded single-host deployment. Each subsystem
owns a fixed, distinct table namespace. The host prepares and checks every
store sequentially and keeps ApplicationLifecycle unready until all required
components have succeeded. Restart must reuse the same validated database URL,
quotas, namespaces, and encryption keys; all handles must close before a host
copies, restores, or replaces the file.
The executable facade contract proves persistence and idempotent replay across restart, encrypted token/message plaintext absence from the database/WAL/SHM, queue recovery, aggregate readiness, and fail-closed token corruption without breaking an unrelated mail read. It does not create a cross-subsystem transaction, consistent online backup, key manager, distributed database, or multi-host coordination. Whole-file snapshot consistency, filesystem trust, permissions, key/backup operations, contention policy, and recovery drills are host responsibilities. Separate databases remain preferable where failure isolation or write throughput matters.
Native relational enum invariant
rullst-orm has one bounded native-enum contract. #[derive(Enum)] generates
a closed label set shared by Display, parsing, Serde, RullstValue and SQLx
codecs. A database enum has 1–64 unique labels; its type identifier and labels
use the documented bounded ASCII allowlists. Blueprint::native_enum emits:
- a named PostgreSQL enum with exact existing-label drift detection only under
strict-postgres; - an inline
ENUMfor MySQL/MariaDB; and - a
TEXT CHECKconstraint for SQLite.
The PostgreSQL type is created as a quoted, case-preserving identifier, and the
generated SQLx codec names it quoted as well, so a mixed-case type_name
resolves to exactly that type. A manual sqlx::Type implementation for a
mixed-case DatabaseEnum::TYPE_NAME must quote the name the same way.
PostgreSQL enum creation, its label drift check and
Schema::drop_native_enum run through the task-scoped transaction when one is
active, exactly like the table DDL: they roll back with it, and dropping the
type after its tables in one transaction cannot block on that transaction’s
own locks from a second pooled connection.
Generated builder comparisons (=, !=, <, >, IN, BETWEEN, with
their or_/not_ and where_<column> forms) on a persisted field whose type
implements DatabaseEnum bind the value as CAST(? AS "<type_name>") on
PostgreSQL, where a text parameter has no operator against a named enum; the
other backends keep ?. The live PostgreSQL matrix filters such columns.
PostgreSQL through SQLx Any must fail before DDL because that driver cannot
decode custom PostgreSQL types. Adding, removing or reordering variants,
deployment order, dependent-object removal and rollback remain explicit,
reviewed migration work. The schema helper does not auto-migrate an existing
type or infer application compatibility.
Blueprint::timestamps() adds nullable created_at/updated_at columns
that default to the database’s current time. With a strict driver feature they
are timestamp_tz() columns (below), read as chrono::DateTime<Utc>. The SQLx
Any pool cannot read date/time values, so there they stay TEXT columns for
String fields: SQLite and PostgreSQL receive DEFAULT CURRENT_TIMESTAMP, and
MySQL/MariaDB, which accept a default on TEXT, BLOB, JSON or GEOMETRY
columns only as an expression, receive DEFAULT (CURRENT_TIMESTAMP); the
builder parenthesizes every other non-NULL default on those column types
(MySQL 8.0.13+, MariaDB 10.2.1+). The strict-pool columns are a v13 change;
alpha.1 created TEXT on every pool.
Schema constraints, indexes and date/time columns
The Blueprint declares constraints and indexes next to the columns, and every
table, column, constraint and index name passes the identifier grammar
(validate_table_name); constraint and index names are at most 63 bytes so
PostgreSQL never truncates them.
Column::unique()emits a column-levelUNIQUE.unique(&[cols])emitsCONSTRAINT <table>_<cols>_unique UNIQUE (...)insideCREATE TABLE.index(&[cols])andunique_index(&[cols])(<table>_<cols>_index/_unique) run asCREATE [UNIQUE] INDEX IF NOT EXISTSafterCREATE TABLEon PostgreSQL and SQLite and areINDEXclauses ofCREATE TABLEon MySQL/MariaDB, which has noIF NOT EXISTSfor indexes.foreign(col).references(table, col)emitsCONSTRAINT <table>_<col>_foreign FOREIGN KEY ... REFERENCESwith the quoted, PostgreSQL-folded referenced table and optionalON DELETE/ON UPDATECASCADE,RESTRICT,SET NULLorNO ACTION;foreign_id(col, table)also adds theINTEGER NOT NULLcolumn and referencestable(id).SET NULLon aNOT NULLcolumn fails the build.check(expr)emitsCHECK (expr). The expression is trusted developer SQL: the build rejects an empty or longer than 512-byte expression and one with;,--,/*,*/,\,$, a backtick, a control character, unbalanced parentheses or an unterminated quote, so it cannot leave its clause; it is never built from request data. MySQL enforces CHECK from 8.0.16, MariaDB from 10.2.1.- On MySQL/MariaDB a key (column
UNIQUE, unique constraint, index or foreign key) on a column whose type isTEXT,BLOBorJSONfails the build: those servers index such columns only by a prefix, which would make a unique key compare only the prefix.
Schema::create validates every statement before running the first. Without a
table name, Blueprint::build() leaves the default constraint names to the
database and omits the separate PostgreSQL/SQLite index statements.
Schema::table alters an existing table: ALTER TABLE ... ADD COLUMN per
column, then ADD CONSTRAINT ... UNIQUE, CREATE [UNIQUE] INDEX, ADD CONSTRAINT ... FOREIGN KEY and ADD CHECK, all validated first and run in
order in the task-scoped transaction when one is active (MySQL/MariaDB commit
each DDL statement). SQLite cannot add a constraint to an existing table: a
unique constraint or unique() column becomes the equivalent CREATE UNIQUE INDEX, and a foreign key on a column added in the same call becomes that
column’s REFERENCES clause (the column must then be nullable without a
non-NULL default). A SQLite foreign key on an existing column, a second
foreign key on the same new column (one REFERENCES clause fits), a CHECK, a
primary-key column, a NOT NULL column without a default and a current-time
default return Error::UnsupportedSchemaChange { driver, change } before any
statement runs.
date(), time(), timestamp() and timestamp_tz() are columns for
chrono::NaiveDate, NaiveTime, NaiveDateTime and DateTime<Utc> fields
(rullst_orm::chrono): DATE, TIME, TIMESTAMP and TIMESTAMPTZ on
PostgreSQL; DATE, TIME(6) and DATETIME(6) (a UTC instant for
timestamp_tz, because TIMESTAMP ends in 2038, with SQLx setting the session
time zone to +00:00) on MySQL/MariaDB; and ISO-8601 TEXT on SQLite in the
forms SQLx writes (YYYY-MM-DD, HH:MM:SS[.f], YYYY-MM-DD HH:MM:SS[.f],
YYYY-MM-DDTHH:MM:SS[.f]+00:00), so values of one column sort as text. A
ColumnDefault::CurrentTimestamp default is the current UTC time in the
column’s form (CURRENT_TIMESTAMP for TIMESTAMPTZ, CURRENT_TIMESTAMP AT TIME ZONE 'UTC' for the zone-less PostgreSQL types, CURRENT_TIMESTAMP(6),
(CURRENT_DATE) or (CURRENT_TIME(6)) on MySQL/MariaDB, and on SQLite
strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now') for timestamp_tz). These
columns and chrono fields need a strict driver feature: on the SQLx Any
pool Schema::create/Schema::table reject the native helpers on PostgreSQL
and MySQL/MariaDB with Error::Validation before DDL, because a table holding
such a column could not be read. RullstValue gains DateTime,
NaiveDateTime, Date and Time variants, bound through SQLx’s chrono
codecs on strict pools and as that ISO-8601 text on the Any pool.
soft_deletes() keeps a TEXT deleted_at column. The Turso adapter
(TursoMigration, TursoModel) writes its own SQL, so none of these builders
or date/time codecs apply to it; its timestamps stay text.
Blueprint::float is an f64 column on every driver: DOUBLE PRECISION on
PostgreSQL (whose REAL is single precision and cannot decode as f64),
DOUBLE on MySQL/MariaDB and REAL (8-byte) on SQLite. Column::col_type
still reads REAL before the schema is built; an explicitly replaced
col_type is emitted unchanged. This affects DDL built from now on; columns
created by earlier versions keep their type until a reviewed migration alters
them (for example ALTER TABLE t ALTER COLUMN c TYPE DOUBLE PRECISION).
Blueprint::boolean is a bool column: BOOLEAN on PostgreSQL, which has
no implicit integer/boolean casts for bound parameters or decoding, and an
INTEGER 0/1 flag on MySQL/MariaDB and SQLite. On PostgreSQL an integer
ColumnDefault of 0/1 renders as FALSE/TRUE and any other integer
default fails the build. Column::col_type still reads INTEGER before the
schema is built. Only newly built DDL changes: an existing PostgreSQL column
stays INTEGER until migrated (ALTER COLUMN c TYPE BOOLEAN USING c <> 0),
and a model that paired boolean() with an integer field on PostgreSQL must
use bool for new tables.
The Capital row also includes one implemented, feature-gated quota boundary:
BillingSubject binds a shared team/workspace counter to trusted tenant state,
Billable::quota_request derives the limit from the subscription owner, and
QuotaStore atomically reserves idempotent units before resource creation. The
deterministic local store is always available; quota-sql uses fixed-schema
SQLx transactions on SQLite/PostgreSQL/MySQL/MariaDB, with exact replay/release
and a caller-owned transaction path for atomic domain writes. Live container
contracts exercise all four protocols. Membership establishment, plan state,
migrations, reconciliation and non-relational adapters remain application
boundaries.
3.1. Generated Academy activity boundary (removed in v13)
v13 replaced the complete Academy scaffold with the LMS starter, which became
the LMS example (examples/lms) in 13.0.0-alpha.4. The generated
ActivityEvaluator, activity attempts, ScoreEvent recording, leaderboards and
the spaced-review queue are no longer generated. Their design remains in the
git history and on the frozen v12 branch; an application that adds them owns
the same server-authoritative controls.
⚡ 4. Core API Specifications (rullst-core)
rullst-core provides the runtime kernel. Database and queue drivers are modular and feature-gated.
4.1. Server & Routing (rullst::routing)
- Routing Macro: Central declarative routing declared via the
routes!macro wrapping Axum routing handlers:#![allow(unused)] fn main() { use rullst::{response::Html, routes}; async fn home() -> Html<&'static str> { Html("Home") } async fn posts_index() -> Html<&'static str> { Html("Posts") } let router = routes![ get("/" => home), get("/posts" => posts_index), ]; } - Server Lifecycle & Graceful Shutdown:
#![allow(unused)] fn main() { use rullst::{Server, routes}; async fn serve() -> Result<(), rullst::server::ServerError> { let router = routes![get("/" => || async { "OK" })]; Server::new(router).run(3000).await } }ApplicationLifecycleis the opt-in orchestration contract behind a lifecycle-aware server. Its phase is monotonic, its immutable registry has at most 32 validated required-component labels, and request admission requires bothReadyand every component bit. ExactGET/HEADhealth probes bypass admission, and theServerrate limiter and Traffic Shield, so/readycan return a bounded503during startup, dependency failure or drain while/healthstays process-only. The JSON reports counts, not labels or dependency errors. Itsversionis the framework version; the v13health_router_with_versionandhealth_router_with_lifecycle_and_versionadd the application’s own version asapp_version.Server::run_with_shutdownaccepts a caller-owned future; when it resolves, the lifecycle changes to draining before Axum waits for accepted requests and then becomes stopped. Dependency checks/timeouts, component updates, replica consensus, load-balancer timing, authorization and the deployment termination deadline remain host contracts. - Access log and default log filter:
Serverprints one[HTTP] METHOD path -> status (ms)line per request on stdout. Since 13.0.0-alpha.3, in Staging and Production a/healthor/readyrequest with a status below 400 is not printed (a failing probe still is);RULLST_LOG_HEALTH_PROBES(true/false, also1/0,yes/no,on/off, from the process environment and then.env) overrides that default in every environment, and any other value stops startup withServerError::Configuration. The tracing subscriber thatServer::runandtelemetry::init_telemetryinstall readsRUST_LOG; when it is unset or does not parse, the filter isinfo,sqlx=warn, so SQLx’s own connection and statement events below WARN are dropped whilerullst_ormspans stay at INFO. - Trusted-Proxy Client Resolution (v13): The socket peer
(
ConnectInfo<SocketAddr>) is the default client identity.security::TrustedProxyLayerimplementsProductionMiddlewareStage::TrustedProxyandServer::trusted_proxies(TrustedProxyConfig)mounts it outside the security baseline, lifecycle, Traffic Shield and rate limiter in both the static and hot-reload servers (the hot-reload server now suppliesConnectInfotoo). It never rejects a request, so exact health probes keep their rate-limit and Traffic Shield exemption behind it.TrustedProxyConfig::newvalidates at most 64 IPv4/IPv6 addresses or CIDR networks, rejects/0, host bits and duplicates, and normalizes IPv4-mapped IPv6; an empty policy never reads forwarding headers. Only a trusted socket peer’sX-Forwarded-For(default) or RFC 7239Forwardedheader is read, never both. The list is walked right to left, skipping trusted hops; the first untrusted address is the client, or the left-most when every hop is trusted. Text left of the client entry is never parsed, and at most 4,096 bytes and 32 entries are examined. Ports, brackets and IPv4-mapped forms are normalized;unknown, obfuscated identifiers and malformed entries are rejected. A missing, malformed, oversized or over-long header keeps the peer without failing the request and emits onetracingevent without header contents. On successConnectInfobecomes the client IP with port 0; every request with peer metadata receives aClientAddrextension without a public constructor.trust_forwarded_proto(true)reports the trusted peer’s scheme (oneX-Forwarded-Protovalue or the selectedForwardedelement’sproto=) asClientAddr::forwarded_proto.Rullst.tomlaccepts the same policy as[security]trusted_proxies,trusted_proxy_headerandtrust_forwarded_proto, validated with the rest of the security configuration; an explicit builder policy replaces it. Only the real proxy networks may be listed, because any host inside them can choose the client address. Since 13.0.0-alpha.2 the static server’s startup line (Rullst framework serving on http://<address>) marks an address that covers every interface (0.0.0.0or::, the staging and production default withoutHOST) and, when a trusted proxy policy is enabled, suggestsHOST=127.0.0.1so a proxy on the same host cannot be bypassed; since 13.0.0-alpha.4 the same line says to keepHOST=0.0.0.0when the proxy reaches the application over a container network, as in the generated compose deployment. The default bind address is unchanged. - Default Dynamic Cache Boundary:
headers_middlewaresuppliesCache-Control: no-storeonly when the handler has not already selected an explicit cache policy. Versioned public/static responses can therefore opt into reviewed caching without weakening the default for dynamic data. The standard/staticmount does so (v13):2xx/304responses for a content-hashed file name getpublic, max-age=31536000, immutable, other static filesno-cachewithServeDir’sETag/Last-Modifiedvalidators; see static assets. Since 13.0.0-alpha.2rullst::cache_controloffers explicit policies for dynamic routes:cache_control::public(max_age)andprivate(max_age)are layers that setpublic, max-age=<seconds>orprivate, max-age=<seconds>on the2xx/304responses of the routes they wrap when the response has noCache-Controlyet (errors keepno-store), andCacheControlcan also be returned from a handler as a response part. The CSRF middleware never sets its cookie on a response that shared caches may store (aCache-Controlwithpublicors-maxageand withoutprivateorno-store). - Strict Transport Security (v13):
headers_middlewareaddsStrict-Transport-Securitywhen the response has none, built from[security]hsts_max_age_seconds(default 31,536,000),hsts_include_subdomains(defaulttrue) andhsts_preload(defaultfalse):max-age=31536000; includeSubDomainsby default.preloadis an opt-in consent to the browsers’ preload list;SecurityConfig::validate(and thereforeServerstartup) rejects it withoutincludeSubDomainsor with a max-age below 31,536,000. 13.0.0-alpha.1 always sentmax-age=63072000; includeSubDomains; preload.rullst_security::SecureHeadersConfig::default()uses the same default value. - WAF Body Exemptions and Development Report (v13):
[security]waf_body_exempt_pathslists exact absolute paths (validated likecsrf_signed_webhook_paths; invalid entries fail startup) whose request bodieswaf_middlewareneither reads nor inspects, a deliberate per-route risk acceptance; their path, query, cookies,RefererandUser-Agentstay inspected. In developmentapply_security_baselinemounts a report-only WAF instead of none: it applies the same rules, passes every request (replaying bodies over the 1 MiB limit in full) and logs awarnevent (targetrullst::security::waf) with only the rule name and path for each request staging and production would refuse. The WAF never reads amultipart/form-databody; it marks such a request on a non-exempt path so thatuploads::Multipartinspects each text field it reads (see Multipart Form Uploads). - Double-Submit Form Contract:
csrf_middlewareinstalls the exact request-scopedCsrfTokenused by the CSRF cookie on eligibleGETandHEADrequests (HEAD mirrors GET’s headers, including a new cookie, so agetroute extracting the token also answers HEAD) and preserves it after a valid state-changing request. Server-rendered forms must echo that value in_token; HTMX/JavaScript may instead send it throughX-CSRF-Token. Amultipart/form-dataform (such as a file upload) must place its_tokenfield before any file input: the middleware reads at most the first 64 KiB of that body to find the field, compares it in constant time and then passes the whole body on unchanged. Since 13.0.0-alpha.2 a multipart body without a readable_tokenfield is refused with a403that names this rule (Missing CSRF token: a multipart/form-data form must send its _token field before any file field …) instead of the genericInvalid or missing CSRF token; a readable but wrong token keeps the generic text. AGETorHEADfor a static-looking path (/robots.txt,/sitemap.xml,/favicon.ico,/static/*, or a.txt,.xml,.ico,.json,.css,.js,.png,.jpg,.jpeg,.svg,.webpor.wasmsuffix) never mints a token or sets the cookie; since 13.0.0-alpha.2 it still receives theCsrfTokenof a valid existing cookie. Nested application andServerbaseline composition is request-idempotent: exactly one CSRF layer owns token validation/cookie emission, so an explicitly protected router remains valid when the production server wraps it. The cookie intentionally remains script-readable and must not be confused with an authentication or session cookie. - Secure Framework Cookies over HTTPS (v13): the
rullst_csrfcookie carriesSecurein staging and production and, since 13.0.0-alpha.2, in any environment when the request arrived over HTTPS: anhttpsrequest URI (HTTP/2 over TLS or an absolute-form target) or aClientAddr::forwarded_protoofhttps, which onlyTrustedProxyLayersets for a trusted peer withtrust_forwarded_proto; a forwarding header from any other peer is ignored.apply_security_baseline(and soServer) mounts a layer outside CSRF in every environment that appends; Secureto eachSet-Cookieof arullst_*cookie (such as therullst_sessioncookie ofrullst-auth) without it on a response to such a request. Other cookies are untouched andSecureis never removed. - Multipart Form Uploads (v13):
uploads::Multipartis an axum extractor formultipart/form-data(a missing or malformed boundary isNotMultipart, 415);Multipart::read(&MultipartPolicy)parses the body incrementally into anUploadFormof text fields andUploadedFiles.MultipartPolicy::default()allows 10 MiB per file, 16 MiB per body (a largerContent-Lengthis refused before reading), 4 files, 32 text fields of 64 KiB, 128-byte field names, 32 KiB of each file in memory (at most 1 MiB) and 30 s between body chunks; part header blocks are limited to 8 KiB. Exceeding a limit stops reading with a typedMultipartError(413 for sizes and counts, 400 for a long name, oversized headers, malformed structure or a non-UTF-8 field, 408 for the idle timeout). The parser accepts a body that starts with the first boundary, CRLF or--after each boundary, never reads past the closing boundary, rejects nestedmultipart/*parts, repeatedContent-Disposition/Content-Typeheaders and repeated disposition parameters, and stays linear for tiny frames and many tiny parts. A part withfilename=""and no content (an empty file input) is skipped and not counted. Larger files stream to.rullst-upload-<uuid>.partintemp_dir(defaultstd::env::temp_dir()), created withcreate_newand mode0600on Unix and removed when theUploadedFiledrops, also on every error. Before a process first writes a temporary file to a directory, and again once 24 hours have passed (13.0.0-alpha.3), it sweeps the leftovers of a crashed process there: regular files named exactly.rullst-upload-<32 lowercase hex>.partand modified at least 24 hours ago, never a symbolic link or a directory, examining at most 10,000 entries and removing at most 1,000; a failure is arullst::uploadswarning and the upload continues.UploadedFile::persist_tonever overwrites (an existing path or symbolic link isPersistTargetExists), requires a file name in an existing directory without..(InvalidPersistTarget), hard-links on the same file system, copies otherwise, and syncs the new0600file.allowed_kindsandexpect_signature(1–64 bytes) check the bytes while they stream, with theUploadPolicysignature and plain-text rules (ContentDenied, 415); the declared type and the sanitizeddisplay_name(last path segment, no control or invisible format characters, at most 255 bytes) are metadata only. The extractor ignores axum’sDefaultBodyLimit. Where the WAF runs and the path is not inwaf_body_exempt_paths, it marks multipart requests andreadapplies the WAF signatures to each text field name and value:WafRefusedwith the WAF’s403in staging and production, awarnlog (rulemultipart-field-signature, path only) in development. A first_tokenfield that ends after the first 64 KiB without anX-CSRF-Tokenheader isCsrfTokenTooLate(403, the middleware’s text) in every environment. Error responses are fixed plain text withCache-Control: no-store. - Database URL Resolution:
Serverand Artisan (artisan!,check_and_run_artisan,db:*,studio) share one resolver: aServer::with_dbvalue whenServer::runintercepts the command, the processDATABASE_URL,DATABASE_URLin./.env(never overriding the process environment), then[database].urlparsed as TOML. There is no implicit SQLite fallback: adb:*command without a configured database, and any configuration orOrm::initfailure, exits with status 1. Parse errors report positions only, never file content.Server::runhas no migration or seeder registry, so adb:*command it intercepts (because the application never calledartisan!) exits with status 1 and namesrullst::artisan!instead of reporting success for an empty registry;studiostill runs. LikeServer, Artisan never loads.envinto the process environment (13.0.0-alpha.1 did after resolving the database), so a migration or seeder reads other.envvalues withrullst::config::project_settingand behaves the same undercargo runandcargo run -- db:seed; the legacystudiopage checks AI provider keys the same way. Rullst Studio reuses an existing ORM pool; only when none exists does its first database view resolve the current directory with this same resolver and initialize the pool once. Without a configured database Studio reports its database tools as unavailable and creates nothing. - Startup Migrations (v13):
Server::auto_migrate(bool)andRULLST_AUTO_MIGRATE(true/false,1/0,yes/no,on/off; the process environment, then.env, overrides the builder; default off) apply the migrations registered withServer::with_migrations(featureorm) after the pool opens and before binding, through the ORMmigratecommand, in both the static and the hot-reload server. PostgreSQL and MySQL/MariaDB runs hold the runner’s advisory/GET_LOCKlock; an SQLite file run also holds an exclusive OS lock on<database>.rullst-migrate.lock(in-memory databases take none). A failed migration, a missing registry or database, an unparsable value or an untakeable lock stops startup withServerError::Migration(orServerError::Configurationfor the value); without theormfeature an enabled setting isServerError::Database. Since 13.0.0-alpha.2 the SQLx starters thatcargo rullst newgenerates (Blank, API and SaaS, including their internal hot-reload layouts; the LMS, Blog, Portfolio and ERP starters of that release are now examples) callwith_migrationswith their registry and leaveauto_migrateunset, so onlyRULLST_AUTO_MIGRATEturns it on. Turso starters register none: their migrations run throughdb:migrate, and the variable set to true there fails startup withServerError::Migration. - Transactional Migrations (v13): A migration whose
within_transaction()returnstruerunsup()(ordown()) and itsmigrationsrecord inside oneOrm::transaction, whichSchema, generated model methods,execute_query!/dispatch_executor!andOrm::raw(sql).execute()join. Since 13.0.0-alpha.3, while that body runs on the runner’s task,Orm::pool(),Orm::read_pool(), theirtry_forms andOrm::begin_transaction()returnError::Validationnaming the migration instead of a connection outside the transaction, so the run fails and the migration rolls back unrecorded. A pool obtained before the body ran, another crate’s pool and tasks spawned from the body are not detected. PostgreSQL and SQLite roll the DDL back with the record; MySQL/MariaDB commit DDL implicitly. - Application Settings (v13):
rullst::config::project_settingreads one application setting from the process environment, then from./.env, which never overrides the process environment and is never loaded into it, neither byServernor by Artisan commands. An unreadable or malformed.envis aConfigErrorthat never contains file content. Nexusbasic_from_envreads its administrator credentials and generated billing code itsBILLING_*settings through it; generated billing treats a productionRullstConfig::environment()(or an invalid one) as production.
4.2. Server-Side Rendering (rullst::html!)
- Macro:
html!expands supported HTML trees into ordinary RustStringconstruction at compile time and returns it asrullst::html::Markup(since 13.0.0-alpha.2; earlier versions returnedString). Markup: a newtype over aStringof already-escaped HTML. It implementsDisplay,AsRef<str>,Deref<Target = str>(read-only),PartialEqwithstr,&strandStringin both directions,Default,Clone,Hash,From<Markup> for Stringand for the axum body, andIntoResponsewithcontent-type: text/html; charset=utf-8;into_string()andas_str()return the HTML. There is no mutable access to the text and no conversion fromString: only the explicitMarkup::from_trusted(impl Into<String>)andFrom<RawHtml>trust their input.render_page/render_page_with_langtakeimpl Into<Markup>;Live::mountand native#[island]functions returnMarkup, and a#[live_component]rendermethod may returnMarkuporString.- Caller bindings: Generated output-buffer locals use hygienic identifiers;
interpolated expressions retain caller name resolution, including
sin fragments, attributes, nested elements and closures. Each expression keeps its existing evaluation order and escaping behavior. - XSS Protection: Dynamic display values in the supported
{expr}syntax are HTML-escaped by the generated code. - URL attributes: Dynamic values of
href,src,action,formaction,poster,data,cite,background,codebase,icon,longdesc,manifestandusemap(names matched case-insensitively) are also checked at runtime. Ajavascript:orvbscript:scheme, ordata:outsideimg,audio,video,source,trackandimage, renders as#. Leading C0 controls or spaces and embedded tab/LF/CR are ignored while reading the scheme, as browsers do. Static attribute strings are author-owned and unchanged; entity references inside an explicitRawHtmlvalue are not decoded before the check. - Event-handler attributes: A dynamic value for an
on*attribute or htmx’shx-on/hx-on-*(alsodata-prefixed) is a compile-time error, because the browser runs it as JavaScript and escaping cannot make interpolated data safe there. Static handler strings remain allowed; attach data-driven listeners from a nonce’d script and pass values indata-*. - Trusted-only
style: Dynamicstylevalues are only HTML-escaped. They cannot leave the attribute and supported browsers do not run script from CSS, but untrusted CSS can restyle or overlay the page and load remote resources, so pass only trusted or validated values. - Composition: An interpolated
Markup(or&Markup) is inserted as it is, so a fragment built by anotherhtml!call nests without a wrapper, whileString,&str, numbers and other values are escaped.Option<T>renders nothing forNoneandTforSome; an iterator ofMarkupcollects into oneMarkup(FromIterator,Extend). CollectingMarkupinto aStringdeliberately does not compile. In an attribute value (name={value}) aMarkupis escaped like text (HtmlEscape::escape_attribute), because HTML there could end the attribute;RawHtmlstays verbatim in both contexts. - Raw Unescaped HTML:
rullst::html::RawHtml(String)(orMarkup::from_trusted) is the explicit escape hatch for trusted HTML produced outsidehtml!, such as a sanitizer’s or renderer’s output. It is never needed forhtml!output and must never wrap request data. - Attributes:
name="text"is author-owned source and written as it is.name={value}is always written with the value escaped: aboolas"true"/"false"(whataria-*anddata-*expect) andNoneas an empty value. Since 13.0.0-alpha.2 a barename(<input required disabled>) is written without a value, and a conditionalname?={value}is decided at runtime throughrullst::html::OptionalAttribute: aboolwrites the bare name or nothing, and anOption<T>writesname="value", escaped exactly likename={value}(URL attributes keep the scheme check above), or nothing forNone. Any other value type is a compile-time error at the expression. HTML boolean attributes mean presence, sodisabled="false"still disables a control: usedisabled?={flag}. The quoted forms keep compiling. Attribute names are Rust identifiers (keywords included) joined by-, so no quote,=,>or whitespace can reach the output through a name;:and@name forms are not supported. A conditional value on an event-handler attribute is rejected like a dynamic one, while a bare handler name is static. - Control flow: Since 13.0.0-alpha.2,
@if condition { … }(also@if let PATTERN = value),@else if …,@else { … }and@for PATTERN in iterable { … }takehtml!nodes in their braces; the heads are parsed like Rust’s ownif/forheads and keep caller name resolution. Since 13.0.0-alpha.3,@match value { PATTERN => { … } … }does the same formatch: each arm is a Rust pattern (|alternatives andname @bindings included) with an optionalifguard, its body is in braces, and commas between arms are optional. The arms expand to a Rustmatch, so exhaustiveness and pattern errors are reported by the compiler at the caller’s tokens, and bodies follow the same escaping andMarkuprules as every other node. An arm body without braces is a compile-time error that shows the braced form. No other node can start with@, so the forms are unambiguous. A plainelseafter an@ifbody and any other@form (@let,@while,@loop) are compile-time errors: other control flow stays a Rust expression that returnsMarkupinside{…}. - Element names and the doctype: Element names (opening and closing) may
be Rust keywords, such as SVG’s
<use>. A leading<!DOCTYPE html>(any letter case) is emitted as written; it must precede every other node (comments, which produce no output, may come first), and other declarations are compile-time errors. - Memoize keys:
#[memoize]keys its process-local cache entries by the function’smodule_path!(), name and attribute location plus the serialized arguments, so same-named functions in different modules, crates orimplblocks never share results. It remains tenant- and invalidation-unaware. The process-wide store is bounded: at most 4,096 entries and 32 MiB of key plus value bytes, each entry at most 256 KiB and one hour old. Oversized entries run uncached; otherwise expired and then the oldest entries are evicted. Arguments thatserde_jsoncannot represent (a failingSerializeimpl, or au128/i128outside the 64-bit range) also run uncached instead of panicking, as do arguments whose JSON would equal another value’s (NaN or infinite floats,Some(()),Some(None)); such results are never cached; an#[island]with such props renders its server HTML with an emptydata-props, so hydration is skipped. A memoized function may returnMarkup; its HTML is cached and read back as the sameMarkup. - Example:
#![allow(unused)] fn main() { use rullst::html; let username = "<script>alert('xss')</script>"; let rendered = html! { <div class="user-badge"> <span>"User: "{username}</span> </div> }; // Automatically escapes to: <script>alert('xss')</script> // `rendered` is Markup: another html! inserts it without escaping it again. let card = html! { <section>{rendered}</section> }; assert!(card.starts_with("<section><div class=\"user-badge\">")); // Bare and conditional attributes, `@if`, `@for` and `@match`. let tasks = [("Write tests", true), ("<Ship>", false)]; let list = html! { <ul> @for (title, done) in tasks { <li><input type="checkbox" disabled checked?={done} />{title}</li> } </ul> @if tasks.is_empty() { <p>"Nothing to do"</p> } @match tasks.iter().filter(|(_, done)| !done).count() { 0 => { <p>"All done"</p> } 1 => { <p>"One task left"</p> } left => { <p>{left} " tasks left"</p> } } }; assert_eq!( list, "<ul><li><input type=\"checkbox\" disabled checked />Write tests</li>\ <li><input type=\"checkbox\" disabled /><Ship></li></ul>\ <p>One task left</p>" ); }
4.2.1. Translations and Locale Negotiation (rullst::i18n, since 13.0.0-alpha.2)
- Placement: the runtime is the Core module
rullst::i18n(no Cargo feature, not built forwasm32); the catalog macro isrullst_macros::i18n_catalog, re-exported asrullst::i18n::catalog!. Guide: Translations and Locales. - Catalogs:
catalog!(default = "tag", dir = "path")(dirdefaults tolocales, relative toCARGO_MANIFEST_DIR; absolute paths are rejected) reads every*.tomlfile of the directory at compile time, at most 64 files of at most 1 MiB. The file stem is the locale tag,language[-Script][-REGION]in canonical case. Nested tables form dotted keys of ASCII letters, digits,_and-. A table whose entries are all strings named after CLDR categories and that hasotheris a plural message; since 13.0.0-alpha.3 any ofzero,one,two,few,manyandotherthe locale’s rule selects is accepted. Templates use{name}placeholders (Rust identifiers that are not keywords) and{{/}}. - Compile-time checks: the build fails on a key missing from any locale,
differing placeholder sets or plural/single shapes between locales, a
malformed template, a plural message whose forms do not match the locale’s
rule, a non-canonical file tag, and a
defaultwithout a file (at most 30 catalog errors are listed). Each file is tracked withinclude_bytes!, so edits rebuild the crate. Stable Rust gives a macro no way to watch a directory, so the documented setup adds abuild.rsprintingcargo::rerun-if-changed=<dir>: Cargo then rescans the directory and rebuilds the crate when a locale file is added or removed (rullst-macros/tests/locale_rebuild.rsbuilds a probe crate with and without it). - Generated items: in the invoking module,
pub static LOCALES: rullst::i18n::Locales(tags sorted, default index) andmacro_rules! tre-exported withpub(crate) use t;.t!(locale, "key", name = expr, …)takes aLocaleor&Localeand a string-literal key; an unknown key (with a near-miss suggestion), a missing, unexpected or repeated argument, and a plural message withoutcountare compile errors at the caller’s tokens. Arguments are evaluated once, in call order. After a catalog error the macro still defines both items so that only the catalog error is reported. - Output and escaping:
t!returnsCow<'static, str>plain text (borrowed when the message has no placeholders) thathtml!escapes in text and attributes; arguments useDisplay. A key ending in_htmlreturnsMarkup: its catalog template is trusted and its arguments are HTML-escaped. - Plural rules: Since 13.0.0-alpha.3 forms are selected with CLDR’s
cardinal rules (checked against CLDR 48’s sample numbers) for
ar,cs,de,en,es,fr,he,it,ja,ko,nl,pl,pt(pt,pt-BR),pt-PT,ro,ru,tr,ukandzh, any region or script. A plural message must define exactly the categories its locale’s rule selects:otheronly inja,koandzh;one/otherinde,en,nlandtr;one/few/many/otherincs,pl,ruanduk;one/few/otherinro;one/two/otherinhe; all six inar.es,fr,it,ptandpt-PTneedone/otherand may addmany, which CLDR selects for exact millions (otheris used when it is left out). A missing category is a compile error naming it; a category the rule never selects is one too. A plural message in another language, or a Portuguese region other than Brazil and Portugal, is a compile error.countaccepts any integer andrullst::i18n::DecimalthroughPluralCount(the absolute value; integers beyondu64saturate). ADecimalkeeps its visible fraction digits, which CLDR’s operands read (1.0is Englishother,1.5is Frenchone); compact exponents are not modelled.Locale::plural_category(count)returns thePluralCategory(#[non_exhaustive]), orNonefor a language without a rule. - Message selection: a call selects the catalog locale with the
Locale’s tag, else one with the same language (the catalog default first), else the catalog default, so aLocalefrom another crate’s list still renders. LocaleandLocales:LocaleisCopyand exposestag()andDisplay; it is obtained fromLocales::get(ignoring ASCII case),iter,default_locale,negotiate,cookie_locale,accept_languageor the extractor.Locales::new(&'static [&'static str], default)validates 1–64 canonical, distinct tags and a listed default, returningLocaleError(Empty,TooMany,InvalidTag,DuplicateTag,UnknownDefault; non-exhaustive).- Negotiation:
Locales::negotiate(&HeaderMap)returns the first supported tag in arullst_localecookie (LOCALE_COOKIE, across allCookieheaders), else the best match of the firstAccept-Languageheader, else the default. AnAccept-Languagevalue over 4,096 bytes is ignored; only its first 32 ranges are read, and ranges over 35 bytes, withq=0or with a weight outside RFC 9110’s0[.ddd]/1[.000]are skipped. Ranges are ordered by weight with header order kept on ties; each matches a supported tag exactly (ignoring case), else by primary language (the default first, then list order), and*matches the default. - Layer and extractor:
Locales::layer(&'static self)returnsLocaleLayer, whose service inserts the negotiatedLocaleinto the request extensions.LocaleimplementsFromRequestParts; without the layer it rejects withMissingLocale, a500naming the layer. - Switch route:
Locales::switch_route(&'static self)is aPOSTMethodRouterreading a URL-encoded body of at most 4,096 bytes (413above) withlocaleand optionalnext. A malformed form or an unsupported locale is400without a cookie. Otherwise it setsrullst_locale=<tag>; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly, with; Securewhen the request’sEnvironmentextension (else the detected environment, else secure) requires secure defaults, and answers303 See Othertonextif it is at most 2,048 bytes of printable ASCII starting with one/and containing no\, else to/. WithHX-Request: trueit answers204withHX-Redirectinstead ofLocation. CSRF protection comes from the application’scsrf_middleware/security baseline; the route does not exempt itself. - Page language: handlers pass
locale.tag()torender_page_with_langfor<html lang>. - Formatting: Since 13.0.0-alpha.3
Locale::format_number(impl Into<Decimal>),format_percent(ratio),format_currency(minor_units: i64, Currency),format_date(&impl chrono::Datelike, DateStyle),format_time(&impl chrono::Timelike, TimeStyle)andformat_datetimereturnStrings with CLDR 48 conventions compiled in (generated from ICU 78 and CLDR’s date patterns by.github/generate-i18n-format-data.py; no runtime data or network). Data exist forar,cs,de,en,en-GB,es,fr,he,it,ja,ko,nl,pl,pt,pt-PT,ro,ru,tr,ukandzh; another tag uses itslanguage-REGIONentry, else its language’s, elseen’s (Locale::format_data_tag). Numbers use the locale’s decimal and grouping separators, minimum grouping digits (es,it,pl,pt-PTwrite1234), minus sign and Latin digits, and show every fraction digit of theDecimal(Decimal::round_half_evenandround_half_upround explicitly). Percentages multiply the ratio by 100.Currency(BRL,USD,EUR,GBP,JPYconstants with localized symbols;Currency::new(code, minor_digits)for other ISO codes, written as the code;CurrencyError::{InvalidCode, TooManyDigits}) takes amounts in integer minor units, so no floating-point rounding occurs.DateStyleisShort,Medium,Long,Full(CLDR’s date formats) orNumeric(CLDR’syMdskeleton);TimeStyleisShortorMedium(no zone names); both are#[non_exhaustive]. Date and time are joined with CLDR’s date-time format for the date style (ICU’satTimevariant forLongandFull;Numericjoins likeShort). Values are formatted in their own offset; years before 1 are written as ISO numbers. Outputs keep CLDR’s U+202F and U+00A0 spaces. Tests compare every locale with ICU output. - Out of scope: runtime-loaded catalogs, ordinal plural rules,
script-aware matching, compact and spelled-out numbers, time zone names,
non-Gregorian calendars and non-Latin digits.
rullst-mail’sMailLocaleremains separate (MailLocale::from_preference(locale.tag())).
4.3. Durable Queue Timing and Completion History
- SQLite and Redis persist
dispatch_atfor at most 366 days and never claim a job before its stored millisecond due time. Execution remains poll-dependent and at-least-once. - The v13 opt-in
Queue::dispatch_deferredplaces a job by a deadline and up to 24 daily fixed-offset windows: the start of the next allowed window (immediately inside an open one), never after the deadline, and the deadline when no window opens before it.dispatch_deferred_withplaces it at the lowest-value slot of an application-providedCarbonIntensitySourceinside those windows, falling back to the window rule when the source fails or exceeds its timeout. Placement goes throughdispatch_at, so stored rows and keys are unchanged; Core ships no network source and makes no emissions claim.Scheduler::deferrable_taskapplies the window rule to each tick within a maximum delay. Scheduler::taskmatches POSIX five-field cron against UTC. The v13 opt-inscheduler-tzCore/facade feature addsScheduler::task_in_tz(expression, zone, handler), which matches the same fields, day fields included, against the wall clock of an IANA time zone. An unknown zone is rejected at registration withSchedulerError::InvalidTimeZone { zone, message }. A wall time that a daylight-saving gap skips runs at the first instant after the gap (several skipped matches run once there); a wall time that a fold repeats runs once, at its first occurrence, and no match in the repeated hour runs a second time. Zone rules come from the system zoneinfo database when it has the zone, otherwise from the database bundled with thejiffdependency. Fixed instants in tests cover São Paulo, New York’s gap and fold, and UTC equivalence withScheduler::task. The 13.0.0-alpha.3Scheduler::deferrable_task_in_tz(expression, zone, deferral, handler)matches the ticks astask_in_tzdoes and reads the deferral’s windows as wall times of the same zone, with the same rule for their boundaries (a skipped boundary moves to the end of the gap, a repeated one is its first occurrence; a window a gap skips entirely does not open that day);max_delaystays elapsed time, and a window that sets a UTC offset is rejected withInvalidConfiguration. Fixed instants around New York’s spring and autumn changes cover it.- SQLite claims pending jobs in order of their due time, and an immediately dispatched job is due at its enqueue time. A scheduled job, or one handed back for lack of a handler, that became due earlier is therefore claimed before later immediate jobs instead of waiting for the backlog to drain.
- Successful SQLite jobs are deleted by default. The explicit
Queue::sqlite_with_completed_historyconstructor validates a 1–100,000 row limit, changes a processing row tocompleted, and prunes excess history in the same transaction. Queue::purge_completed_historyremoves those opt-in retained successes. Rows contain the original payload, so Studio access, data minimization and retention policy remain host responsibilities. Redis/custom drivers expose inspection or history only when their capability implements it.- Redis stalled-lease recovery measures each lease’s age with Redis server time, the clock that scored the claim, so a worker host’s clock skew cannot recover a running job early or postpone recovery of a crashed one.
- The Redis queue waits up to 10 seconds for each command’s reply (the cache keeps the 500 ms redis-rs default). Redis still applies a script whose reply times out, so a claim lost that way is returned only by stalled-lease recovery; the longer deadline keeps ordinary latency spikes from causing it.
- SQLite never recovers a lease early. A claim with a lease stalls once its
millisecond expiry has passed; a claim made without one (
pop) records only whole seconds, so it is recovered betweenstalled_afterand about two seconds later. - A worker without a handler for a claimed job’s name hands it back with a
five-second delay until the claim’s 720th attempt, then fails it with
HandlerNotFound, so a job whose name no worker registers reaches the failed state instead of cycling forever.Queue::dispatchanddispatch_atreject empty job names and names longer than 256 bytes.
Local object replacement
LocalDriver::put, and Storage::local/TenantStorage over it, never
truncates an existing object. It writes a uniquely named temporary file in the
validated destination directory (create_new), flushes it with sync_all and
renames it over the destination. A failure removes the temporary file and
leaves the previous version. Readers and concurrent writers observe exactly one
complete version; the last rename wins. The replacement is a new file with
default permissions, so permissions or hard links of the previous file are not
carried over. The directory is not fsynced, so a power loss can roll a
completed put back to the previous version. On Windows a replacement fails
while another process holds the object open without delete sharing.
Like the cloud backends, local exists, get, metadata and delete treat
a key that names a directory, or that continues below a regular file, as a
missing object rather than an I/O failure.
Public object URLs
Storage::url and LocalDriver::url percent-encode every byte of each key
segment except the unreserved A-Z a-z 0-9 - . _ ~, so #, ?, %, spaces
and non-ASCII characters stay part of the object key. Local storage returns the
root-relative /storage/<key> path whatever its base directory, so the
filesystem path is never disclosed; the application must serve that directory
at /storage (Rullst does not mount it). An unconfigured S3 driver returns the
provider’s unsigned object URL, following the cloud client’s endpoint rules
(amazonaws.com.cn for cn-* regions, path style for dotted bucket names). An
R2 driver returns StorageError::Unsupported: R2’s S3 API endpoint never serves
anonymous reads, and its public r2.dev or custom domain is application-owned
Cloudflare configuration. A configured private backend rejects url() and
requires a signed download.
4.4. Private S3-compatible storage (v13, source admitted)
The opt-in storage-s3 Core/facade feature extends the existing Storage and
TenantStorage APIs with explicit AWS S3 or Cloudflare R2 credentials. It must
not add cloud dependencies to bare Core or infer credentials from the ambient
environment. The provider constructors alone remain unconfigured until a
validated cloud configuration is attached.
The supported journey is bounded object upload, download, metadata, deletion
and short-lived signed GET URLs. Use the maintained AWS SigV4 signer, explicit
provider endpoint/region rules, HTTPS, no redirects or ambient proxies, bounded
request/body budgets and redacted errors. A separate development configuration
may target a literal loopback endpoint for disposable protocol acceptance; it
must not pass a production-configuration check. Request paths are the SigV4
canonical URI: the bucket and each key segment are UriEncoded once, leaving
only unreserved A-Z a-z 0-9 - . _ ~ and / separators literal. Empty or mock_* credentials
select a deterministic bounded in-memory fallback, also rejected by that check.
No mock URL may impersonate a signed provider URL.
Tenant wrappers bind all five operations to the authenticated namespace. The application still authorizes the current account and object before every operation or grant; namespacing alone is not authorization. Private access URLs are bearer secrets, remain usable until expiry/provider invalidation, and must not appear in Debug/errors. They are not single-use or individually revocable. Direct presigned uploads, bucket/IAM provisioning, image processing and arbitrary S3 operations are outside this increment. Existing file quarantine/admission remains an explicit application step before cloud persistence or sharing.
Acceptance requires feature-off/on and MSRV checks, an extracted-package consumer, a tenant-bound application journey, independent signature/protocol fixtures, bounded failure/expiry tests and a real disposable S3-compatible service. Those tests do not establish interoperability with owner accounts, provider configuration or regional legal compliance. Source/package admission passed in PR #236; final release admission remains separate.
Resumable multipart candidate (source admitted, v13 prerelease)
The separate opt-in storage-multipart extends this adapter with server-mediated
initiation, exact-size SHA-256-checked parts, bounded remote reconciliation,
completion and abort. A server-owned MultipartKey seals resumable checkpoints
with AES-256-GCM under a versioned purpose and exact endpoint/bucket/region/mock
profile/object binding (including the ephemeral instance in mock mode). Each
operation reopens the checkpoint and checks policy;
tenant wrappers derive that object only from the authenticated namespace. The
application must still authorize the current subject/object and serialize or CAS
checkpoint updates. A checkpoint is sensitive recovery state, never identity,
membership, malware clearance or permission to publish an object.
The initial scope fixes total length, part size (5–64 MiB), at most 256 parts and a lifetime of one minute through seven days. Part bytes match a caller-supplied SHA-256 before signing the exact payload with SigV4. ETags are opaque provider receipts, not content hashes. Checkpoints retain receipts; resume lists provider parts and reports missing/mismatched receipts for explicit re-upload. Completion requires every consecutive receipt and rejects an embedded XML error even under HTTP 200. A provider marker and exact length support explicit reconciliation of lost completion responses, without guessing from object existence alone.
Only one part is buffered per call; applications bound concurrent calls. Responses
are byte/node bounded, prohibit DTDs and validate exact root/resource identity.
No public ACL, direct browser upload or ambient endpoint is introduced. Existing
ordinary-object download limits still apply to large completed objects. New
objects may replace a same-name object as with put; applications must allocate
fresh private/quarantine names and withhold reads until scanning and authorization.
Abort remains available for expired, authentic checkpoints and checks provider absence; concurrent in-flight writes can require another abort. Retain cleanup records durably and provision provider lifecycle expiration for abandoned uploads, including initiation whose response was lost before an upload ID was obtained. No application database, global sweeper, bucket provisioning or durable revocation of checkpoints is implied. The offline backend has explicit bounded in-memory state; native disposable S3, process/service restart, forged checkpoints, tenant isolation, response failures and extracted consumers are required evidence.
🗄️ 5. Active Record ORM & Schema Engine (rullst-orm)
5.1. Model Definition & CRUD
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(table = "users")]
pub struct User {
pub id: i32,
pub name: String,
pub email: String,
#[orm(encrypted)]
pub secret_token: Option<String>,
}
async fn use_users() -> Result<(), rullst_orm::Error> {
// Queries (after `Orm::init(...)` and schema migration at startup).
let all_users: Vec<User> = User::all().await?;
let user: Option<User> = User::find(1).await?;
// Mutations
let mut new_user = User { id: 0, name: "Alice".into(), email: "[email protected]".into(), secret_token: None };
new_user.save().await?; // Auto-executes parameterized INSERT or UPDATE
new_user.delete().await?;
let _ = (all_users, user);
Ok(())
}
}
save() of a persisted model without a RowSnapshot field is a full-row
UPDATE of every persisted column except the soft-delete marker, so the last
writer wins: a column another writer changed after the handle was loaded is
overwritten with the handle’s value. An
INSERT likewise binds every persisted field, so database column defaults
do not apply to them, except to fields marked #[orm(db_default)] (v13): the
generated INSERT omits them, so the column DEFAULT applies, and no
UPDATE of save() writes them, so the stored value stays; without dirty
tracking the handle keeps its own value until the row is read again,
update_partial() has no setter for them and #[derive(Nexus)] shows them
read-only. The option is
rejected on id, the tenant column, relations, skipped and encrypted fields,
and by Turso-primary models. The generated save() rustdoc and the ORM guide name
the two supported alternatives: update_partial() (§5.4.1), which merges
selected fields into the freshly read and, on PostgreSQL and MySQL/MariaDB,
locked row, and one atomic raw statement such as
UPDATE ... SET xp = xp + ? WHERE id = ? for counters, which bypasses the
model lifecycle (tenant scope, policy, hooks, audit, cache). The driver
contract runs that counter and the stale-handle overwrite on SQLite,
PostgreSQL, MySQL and MariaDB.
Dirty tracking (v13). A SQLx model opts in with exactly one field of type
RowSnapshot (rullst_orm::RowSnapshot, matched by its last path segment)
marked #[sqlx(skip)]; a RowSnapshot field without #[sqlx(skip)], or a
second one, fails compilation. The tracked columns are the columns of the
UPDATE: every persisted field except id, #[orm(db_default)] fields and
the soft-delete marker. Rows decoded by the generated reads (get and the
methods built on it, relations, streams, query-cache hits and the row
update_partial() locks) record, after decryption and before after_fetch,
one keyed 64-bit SipHash per tracked column of its serde_json
serialization (SecretString contributes its plaintext; #[sqlx(json)],
Json<T> and serde_json::Value columns their document with object keys
sorted at every level; a value that cannot be serialized never matches). The
snapshot is 16 bytes plus 8 per column and stores no value. save() of a
persisted record runs the before_save hook and saving observers, then
compares: with no difference it returns Ok and runs nothing else (no
updating/updated/saved observer, after_save hook, audit entry or
post-commit effect); otherwise it runs the updating observers, recomputes
the difference and issues UPDATE <table> SET <changed columns> WHERE id = ?
(plus the tenant predicate) with RETURNING * on PostgreSQL and SQLite, or
followed by SELECT * ... WHERE id = ? in the same transaction on
MySQL/MariaDB. No row is RecordNotFound (the tenant-scope Validation error
on tenant models). The handle’s unwritten tracked columns,
#[orm(db_default)] columns and soft-delete marker take the stored values,
so observers, the audit diff, events and the Scout document see the stored
row, and the snapshot is recaptured. An insert records the snapshot of the
inserted values. Without a before_save hook, policy or registered observer,
an unchanged save()/save_with_tx() returns before opening a transaction
or savepoint (it still applies and checks the tenant context). A failed save
restores the previous snapshot, so its changes stay pending; a rollback of a
caller-owned transaction after a successful save is not visible to the
handle. A record built in code, one from a Redis hash, or after
mark_all_dirty() is untracked and writes every tracked column. Tracked
models also get dirty_columns() and is_dirty(). RowSnapshot is
bookkeeping: any two compare equal, hash alike and order equal; Debug shows
only whether it is tracked; it implements no Serde trait. Turso-primary
models do not track. Models without the field keep the full-row UPDATE.
Since 13.0.0-alpha.3 every SQLx model the CLI writes (make:model,
generate:models, auth, make:billing, make:chat-session and the
cargo rullst new starters) declares #[sqlx(skip)] pub snapshot: RowSnapshot
and builds new records with RowSnapshot::default(); generate:models adds
#[serde(skip)] and omits the field for a table with a snapshot column.
From<sqlx::Error> for rullst_orm::Error maps RowNotFound to
RecordNotFound, a unique or primary-key violation to
UniqueViolation { constraint } and a foreign-key violation to
ForeignKeyViolation { constraint } (both v13, #[non_exhaustive] variants);
every other database error stays DatabaseError(String). constraint is the
name PostgreSQL reports and None on SQLite and MySQL/MariaDB. Their Display
names only the constraint, never the database message, which can quote the
rejected value. Generated model writes and builders reach the caller through
this conversion, and Orm::raw uses it too.
Orm::transaction returns Result<R, E> for a closure error type
E: From<rullst_orm::Error> + Display. After the rollback, the closure’s
Err reaches the caller unchanged, so a violation keeps its variant and an
application error its type. Failures of the transaction itself (begin,
savepoint, release, commit, post-commit effects) are rullst_orm::Errors
converted with E::from; a commit rejected by a deferred constraint is a
typed violation too. Only a rollback that fails after a failed closure returns
E::from(DatabaseError(..)), naming both errors. 13.0.0-alpha.2 returned
Result<R, rullst_orm::Error> and reported a failed closure as
DatabaseError("Transaction failed: ...").
The Orm derive grammar is fail-closed. Model and field attributes are parsed
as structured nested metadata; unknown or duplicate options are compile
errors. Every SQLx model requires a persisted named id field; a model
whose only persisted field is id inserts the column defaults (DEFAULT VALUES, or () VALUES () on MySQL/MariaDB). Explicit
table/column/relation identifiers use the 1–64 byte portable ASCII identifier
grammar; the derived <struct>s default table name must match it only when
no explicit table replaces it (for example for a non-ASCII struct name), and
declared hook, scope, policy, relation-model, tenant, soft-delete,
and embedding references are validated before code generation. A relation
field accepts exactly one relation declaration; options that do not apply to
that relation fail compilation. belongs_to_many requires pivot_table and
defaults omitted owner/related pivot keys from the two model names.
Generated SQL emits table and column identifiers unquoted, so the grammar
check does not make a name portable: a word the target database reserves
(for example order, group, desc or user on PostgreSQL, or groups and
rows on MySQL 8, including a derived default such as groups for a Group
struct) passes compilation and fails, or on PostgreSQL may even resolve to a
built-in such as current_user, at runtime. The derive does not check
reserved words; rename such a column or choose a non-reserved
#[orm(table = "...")]. Because PostgreSQL folds those unquoted names to
lower case, Schema::create and Schema::drop_if_exists quote the table name
lower-cased on PostgreSQL, so Schema::create("UserProfiles", ...) creates the
userprofiles relation that a model with #[orm(table = "UserProfiles")]
queries. MySQL/MariaDB and SQLite keep the name as written.
Only skip, default, json, and json(nullable) from SQLx field metadata
are compatible with generated ORM persistence in v12 and v13. Since
13.0.0-alpha.4 #[derive(Orm)] implements sqlx::FromRow for the model
itself, so applications build without SQLx’s macros/derive features and
the sqlx-macros crate, and a model must not also derive FromRow (E0119).
The generated impl is generic over the row type like SQLx’s derive and gives
these options SQLx’s meaning: skip fills the field with Default::default()
without reading the row, default does so when the row has no such column,
json/json(nullable) decode through SQLx Json, and a struct-level
#[sqlx(default)] takes a missing column from Self::default(). Relation
fields are never read and start from Default::default(). A model that needs
its own FromRow (written by hand, or SQLx’s derive with SQLx’s derive
feature enabled by the application) declares #[orm(custom_from_row)]. Two
field options keep a field out of generated writes, with different meanings:
| Option | The column | Generated reads (FromRow) | INSERT / save() / update_partial() | WHERE / ORDER BY / GROUP BY / select |
|---|---|---|---|---|
#[orm(skip)] | is not a database column for the ORM | the model’s FromRow still reads it unless #[sqlx(skip)] | never written | rejected at run time with Validation |
#[orm(db_default)] | exists and is managed by the database (a default, a trigger or explicit SQL) | read | never written (the INSERT takes the column DEFAULT) | allowed |
#[orm(skip)] removes a field from generated SQL (writes, filters and
projections) but not from the model’s FromRow, which still reads
the column from SELECT *. A field without a table column therefore needs
#[sqlx(skip)] (alone or together with #[orm(skip)], which are distinct
options) or #[sqlx(default)]. A column the table has that generated writes
must not touch is declared #[orm(db_default)], which keeps it filterable;
the builder’s error for a skipped column names that option. Until
13.0.0-alpha.2 the SaaS, LMS and cargo rullst auth User models declared
is_admin and session_epoch #[orm(skip)]; from alpha.3 they use
#[orm(db_default)], and cargo rullst upgrade reports the old declaration
(V13-ORM-SKIP-MANAGED-COLUMNS). FromRow decodes a json or
json(nullable) field through SQLx Json, and generated INSERT/UPDATE
statements (including update_partial()) encode it the same way: the value is
bound as sqlx::types::Json(value), and a json(nullable) None as SQL
NULL. The field type therefore needs Serde Serialize/Deserialize, not
an SQLx Encode, and the column the driver’s JSON type (JSONB/JSON on
PostgreSQL, JSON on MySQL, text on SQLite). SQLx implements Json only for
the concrete drivers, so such models require a strict driver feature; the
dynamic Any pool cannot decode them. rename, try_from,
flatten, and unknown SQLx options fail compilation instead of letting the
decoded shape drift from generated SQL. The same applies to struct-level
SQLx metadata, which FromRow also reads: only #[sqlx(default)] is accepted
on the model, and rename_all or any other container option fails
compilation. Soft-delete sentinel expressions are
bounded compile-time SQL fragments, not parameterized runtime values: they are
capped at 128 bytes and reject statement separators, NUL, and SQL comments,
while portability and semantic review remain the model author’s responsibility.
5.2. Parameterized Queries & Privacy
- Values accepted by non-raw query APIs use SQLx parameterization. Structural
identifiers use the bounded ASCII grammar. pgvector helpers bind canonical
vector/distance strings after rejecting empty/non-finite vectors and invalid
distances; they do not interpolate those runtime values. Methods explicitly
suffixed/named
rawremain caller-owned escape hatches rather than an injection-safety claim. - Schema DDL cannot bind values.
ColumnDefault::TextandBlueprint::enum_colvariants are emitted as single-quoted literals with doubled single quotes, and building the schema rejects such text when it contains a backslash (an escape character in MySQL/MariaDB’s default SQL mode) or a control character. - Generated builders assemble bindings by emitted clause position (CTE,
SELECT, JOIN, WHERE/HAVING, ORDER BY), not by the order in which fluent
methods were called. Nested typed subqueries export that ordered binding
sequence. Caller-owned raw CTE and select fragments supply their own values
through
with_raw_bindings,with_recursive_raw_bindingsandselect_raw_bindings(v13); a marker/binding count mismatch fails closed, and$nmarkers are rewritten like typed subqueries. Pluck and count replace the select list and therefore omit its values.with_raw,with_recursive_rawandselect_rawfail withValidationwhen their SQL contains a bind marker, becausebind()appends WHERE values and a marker before FROM would take the tenant or model-wide scope binding. - Generated builders start with the global row cap from
Orm::set_max_query_limit(1,000 by default;0disables it).limit()clamps to it andunsafe_unlimited()removes it for one query. Anoffset()without a limit is emitted afterLIMIT -1on SQLite andLIMIT 18446744073709551615on MySQL/MariaDB, which acceptOFFSETonly afterLIMIT; PostgreSQL receivesOFFSETalone.paginate(page, per_page)clampsper_pageto the same cap and reports the effective value inPaginationResult::per_pageandlast_page. count()and thepaginate()total count the rowsget()would return without its limit and offset. Withdistinct()orgroup_by()they count that row query as a derived table (SELECT COUNT(*) FROM (...) AS __rullst_count, keeping a DISTINCT select list); otherwise they count the filtered rows directly.where_exists,or_where_exists,with_cteandwith_recursiveembed a subquery with portable?markers, even when its ownto_sql()rendered PostgreSQL$nmarkers. The outermost statement (includingdelete_all) is numbered once, so$nfollows textual order and the mandatory tenant or model-wide scope binding can never shift onto a nested or caller value. A custom subquery whose$nmarkers are mixed with?, reference a missing binding or leave a binding unused fails closed with aValidationerror.delete_all()renders only the WHERE and soft-delete predicates. A builder with an explicitlimit()(the implicit global cap does not count),offset(),order_by(), joins,group_by()/HAVING or CTEs fails withValidationinstead of silently deleting every matching row. On a soft-delete model,with_trashed()andonly_trashed()fail the same way: the soft-deleteUPDATEmatches only live rows, like the instancedelete(), so a trashed row keeps its deletion time. Purge trashed rows withforce_delete().- Only PostgreSQL statements are renumbered.
delete_all(), including the soft-deleteUPDATEthatcascade_soft_deleteissues for child rows, keeps?markers on MySQL/MariaDB and SQLite; the SQLite test and the live PostgreSQL, MySQL and MariaDB matrices execute filtered, tenant-scoped and cascading bulk deletes. Instancerestore()andforce_delete()statements, including their tenant predicate, are numbered the same way and run in those matrices too. - The PostgreSQL renumbering skips quoted text using PostgreSQL’s default
standard_conforming_strings = onrules: a backslash escapes the next character only insideE'...'strings, so a raw fragment such asESCAPE '\'does not hide the markers after it. A server configured withstandard_conforming_strings = offis outside this contract. - Generated magic filters bind supported primitive fields to their Rust type at
compile time (
String,i32,f64, andbool), and generated column enums make unknown columns unrepresentable on typed paths. String-column builders, customRullstValueconversions and raw SQL are explicit runtime-checked or caller-owned alternatives, not compile-time schema verification. RullstValue,#[non_exhaustive]since v13, carriesString,Int(i32),Float,Bool,I64andNull.i64andu32convert toI64andOption<T>toT’s value orNull; an integer literal staysInt. Generated builders andOrm::rawbind every value through one hiddenBindRullstValuehelper:I64asi64andNullas a text-typed SQLNULL, which PostgreSQL accepts in a non-text column only through a cast. A comparison withNullnever matches (and PostgreSQL rejects it against a non-text column), sowhere_nullexpresses that filter. Tenant IDs take the same values: anI64tenant gets its own audit (i64:) and cache keys but does not convert to ani32tenant field (generated queries fail withValidation, §5.4), and aNulltenant fails the audit lookup withValidation.- A per-column helper (
where_{column},or_where_{column},where_not_{column},order_by_{column},order_by_{column}_desc) is not generated when its name equals a fixed builder method (for examplewhere_raw,where_exists,where_column,where_col,where_similarororder_by_descfor columnsraw,exists,column,col,similarordesc) or when two columns would produce the same name (xandnot_xboth yieldwhere_not_x). Those columns remain available through the string-column methods. Theupdate_partial()builder keeps its model reference under the reserved__rullst_prefix and emits no setter for a column namedsaveorsave_with_tx. StringandOption<String>fields annotated with#[orm(encrypted)]are encrypted before generated ORM writes and decrypted after generated model reads using AES-256-GCM. Randomized ciphertext cannot be filtered, ordered, grouped, or explicitly selected by generated query-builder methods; use a separately reviewed blind index when equality lookup is required. Raw SQL remains an explicit, non-transparent escape hatch. The builder’s encrypted,SecretStringand skipped-column guards compare the (unqualified) column name ignoring ASCII case, like unquoted SQL identifiers, andpluck_stringdecrypts with the declared column name.- Generated secondary projections never carry
#[orm(encrypted)]or#[orm(masked)]plaintext.to_json()(used for audit rows and committedModelCommittedEvent/Redisorm:events:*payloads) omits#[orm(hidden)]fields and replaces encrypted and masked values with the fixed"***"marker. An update that changes only such a field still writes an audit row that records the field as"***"on both sides, and that revision cannot be restored. Scout documents omit hidden, encrypted and masked fields entirely.save_to_redisstores encrypted fields as the same table/column-bound envelope as the SQL column andget_from_redisdecrypts them; hashes written by earlier versions with plaintext in those fields fail closed on read and must be rewritten. Masked and hidden values keep their database representation in Redis hashes and the query cache, like the SQL column. Audit rows, search documents and events written by earlier versions are not rewritten; purge or reindex them if they may contain plaintext. SecretStringnever serializes its plaintext. Its SQLx codec encrypts and decrypts the column.Serializeemits an authenticatedRULLST:v2envelope under the configured key, bound to a serde-specific context (a SQL-column envelope is not accepted), and fails when no key is configured;Deserializedecrypts such an envelope through the current key or keyring and still accepts any other string as plaintext input. The envelope binds only the key, not a model, field or owner, so thatDeserializeis for trusted stores: given client input, it would turn an envelope exposed for one record into another record’s secret. Fields filled from requests use#[serde(deserialize_with = "rullst_orm::privacy::deserialize_plaintext_secret")](ordeserialize_optional_plaintext_secret; v13 API), which reject anyRULLST:value without decrypting it. Generatedto_json()and search projections serialize it (also when nested) as"***", andSecretString/Option<SecretString>model fields are audited, excluded and change-tracked like#[orm(masked)]fields. A plain#[derive(Serialize)]on a model therefore emits the envelope; callreveal_audited()for deliberate exposure. Each write encrypts aSecretStringcolumn with a fresh nonce, so generated builder filters, ordering and grouping on it (includingwhere_<column>helpers) fail withValidationinstead of never matching, andpluck_string/pluck_i32reject it rather than return envelopes. An explicitselectof the column remains available because the codec decrypts it while decoding the model.
5.3. Generated Relationship Contract
- An omitted
foreign_keydefaults to a lowercased model name plus_id:belongs_toreads<related model>_idfrom the declaring model (post_idforbelongs_to = "Post"), whilehas_one/has_manyand the owner side ofbelongs_to_manymatch<declaring model>_idon the other table. Models whose names are not single words usually need an explicitforeign_key.local_key(this model’s matched key, defaultid) applies tohas_one,has_many,morph_one,morph_manyandbelongs_to_many;related_key(the related model’s key) applies tobelongs_to,belongs_to_manyandmorph_to. Either option on another relation fails compilation instead of being ignored. - A relation key may be nullable (
Option<T>, such as a nullable foreign key) on either model; generated loaders compare its inner value. ANonekey matches no row, like SQLNULL: the lazy loader returnsNoneor an empty list without a query, an eager load assignsNone(orSome(vec![])for a to-many relation) to such a parent, and a related row whose key isNonebelongs to no parent. Themorph_toid field stays non-optional. - SQLx models may declare
morph_many,morph_one, and one or more explicit typedmorph_totargets. A polymorphic relation requiresmorph_name = "..."(nameremains a legacy alias). morph_tofails macro expansion unless the source has a persisted bindable<morph_name>_idfield and a persistedStringdiscriminator named<morph_name>_type.foreign_keymay override the ID field andrelated_keymay override the target key.- The discriminator stores the Rust target model name. Lazy loading returns
Nonefor a different target; eager loading batches each declared target and never guesses an undeclared runtime type. Target models used in eager inverse loading must implementClone. - Each eager load (
has_many,has_one,belongs_to,morph_many,morph_one,morph_toand the related query ofbelongs_to_many) issues one query for the whole parent batch. When that query still carries the global row cap, it fetches one row beyond the cap and fails with aValidationerror if the cap would truncate it, so no parent silently receives an empty or partial relation. A constrained eager load that sets an explicit smallerlimit(n)(applied to the whole batch) orunsafe_unlimited()is honored as written. A to-many eager load (has_many,morph_many,belongs_to_many) assignsSome(vec![])to a parent without related rows, soNonealways means “not loaded”. - An eager
belongs_to_manyload hands each parent its related rows in the order of the related query, so theorder_byofwith_<relation>_constrainedapplies as it does to the lazy<relation>_constrainedloader; a related row linked by several pivot rows appears once per pivot row. Without anorder_bythe order is unspecified. - Every parent receives the related rows it shares with other parents (one
belongs_toparent of many children, a non-uniquelocal_key, or duplicated parent rows). The shared value is cloned for every such parent but the last; when the related model does not implementClone, a shared row fails the load with aValidationerror instead of leaving a parent empty. - The relation’s ownership predicate (foreign key, morph id/type pair or
pivot key) forms its own
ANDgroup before a lazy<relation>_constrainedor eagerwith_<relation>_constrainedmodifier runs, like the tenant and model-wide scopes, so anor_wherein the modifier cannot return another parent’s rows.
5.4. Tenant Scope Contract
- A SQLx model declaring
#[orm(tenant_column = "tenant_id")]must have a persistedString,i32,f64, orbooltenant field. The derive rejects a missing or unsupported field type. - Generated queries fail closed when called outside
with_tenant(...)and bind the active tenant inside the scope, converted to the tenant field’s type; a context of another type fails withValidationfor reads and bulk deletes exactly as for mutations, instead of reaching a predicate that MySQL would compare numerically across tenants. Generated full/partial updates and instance delete/restore paths reject a model from another tenant. - The generated tenant predicate, the soft-delete filter and the
chunk_by_idkeyset name their column with the model’s table (<table>.<column>), so a join with a table that has the same columns, such as a tenant-scoped or soft-deletablebelongs_to_manypivot, cannot make them ambiguous. A model-wideglobal_scopeis application code and qualifies its own columns. Model::unscoped()is the explicit global escape hatch. Deciding who may use it, deriving tenant identity from authenticated state, and database-level RLS remain host responsibilities.- SQLx builders keep offset-based
chunk(...)for compatibility and expose falliblechunk_by_id(...)/chunk_by_id_with_tx(...)for stable ascending keyset traversal over the generatedi32primary key. This prevents deletes of processed rows from shifting later rows behind an offset; it is not a database-server cursor or a universal cross-shard snapshot. Without anorder_by,chunk(...)/chunk_with_tx(...)order their pages by<table>.id, because SQL gives consecutive offset queries no stable order (a PostgreSQL synchronized scan, for example, can start mid-table). An explicitlimit(n)caps the rows every traversal hands to its handler in total (the implicit global row cap does not), and an explicitoffset(k)is wherechunk(...)starts and how many rowschunk_by_id(...)skips before its first page. - A model delete with marked
cascade_soft_deletehas-one/has-many relations runs parent and direct-child mutations in one transaction. An existing explicit or task-scoped transaction is reused; otherwisedelete()opens, commits, or rolls back its own transaction. The children are selected with the related model’s tenant scope but without itsglobal_scope, so a child hidden by that model-wide filter is trashed with its parent too. Recursive descendant/cycle traversal remains a separate contract. The related model must itself use soft deletes (adeleted_atfield or#[orm(soft_delete)]): a cascade into a model without them fails to compile (no method__rullst_cascade_soft_delete_with_tx, reported at the relation field) instead of permanently deleting the children of a restorable parent. A policy-protected child cannot be authorized in bulk, so its cascade fails closed withError::Validationand rolls the parent delete back; delete such children individually first. - Generated
#[orm(auditable)]instancesave()/delete()operations (andrestore()/force_delete(), recorded asrestored/force_deleted) write their bounded audit entry through the same explicit, implicit, or task-scoped transaction as the model mutation. Audit write errors fail the mutation and roll its savepoint back; directlog_auditcalls also honor a task-scoped transaction. Every recorded mutation requires anAuditContextnaming a validated user, service, or system principal; the record also carries the optional correlation identifier and derives its typed tenant key from the activewith_tenant(...)scope. The host remains responsible for deriving both contexts from authenticated authority rather than client assertions. An auditablesave()reads the pre-image of its diff inside its savepoint withFOR UPDATEon PostgreSQL and MySQL/MariaDB, so a concurrent writer cannot change the row between that read and theUPDATE. The old side of adeleted,force_deletedorrestoredentry is likewise the persisted row read the same way just before the statement, not the caller’s possibly edited or stale handle. These reads, the revision-restore lookup, the rowrestore()re-reads and thebelongs_to_manypivot query run withinOrm::set_query_timeout, like the other generated statements. create_audit_tablecreates the v2 schema and adds its columns to a legacy table without presenting legacy rows as v2 evidence. On MySQL/MariaDB a new table (or a newly addedrestore_patchcolumn) storesold_values,new_valuesandrestore_patchasLONGTEXT, becauseTEXT(64 KiB) cannot hold the bounded 5 MiB payloads. An existing table is never altered implicitly; while those columns are stillTEXT, the call logs a warning naming the reviewedALTER TABLE ... MODIFY ... LONGTEXTmigration. JSON payloads are bounded and recursively mask sensitive names for create, update, and delete. The reverse patch records only the presence of a sensitive key, including a nested JSON key that an update adds or removes, and such an operation is not restorable. A reverse patch that would exceed its depth (64), operation (4,096) or 5 MiB size bound is omitted: the update and its audit diff are still recorded, and that revision cannot be restored. Audit/debug output does not expose principal, tenant, correlation, reason, or payload values.- An auditable model exposes
restore_revision(audit_id, reason)and its caller-owned transaction variant. Only a bounded v2 update patch for the exact model, ID, and active tenant is eligible. The current row must still match the revision’s recorded post-state; PostgreSQL and MySQL/MariaDB also lock that row during restoration. A successful restore is a compensating audited update referencing the restored audit ID and reason. Legacy, create/delete, oversized, stale, malformed, cross-tenant, or redacted-field revisions fail closed. Bulk builders still do not synthesize per-row history, and durable external export remains an explicit outbox/application contract.
5.4.1. Transactional partial-update candidate (source admitted)
update_partial().save()and its new explicitsave_with_tx(...)variant merge selected typed values into the current persisted row within a savepoint. PostgreSQL and MySQL lock that row withFOR UPDATE; SQLite retains its transaction/locking semantics and can reject contended read-to-write upgrades. Missing rows and cross-tenant model handles fail before mutation.- The merged model uses the normal generated save lifecycle, including policy,
hooks, observers, encrypted fields, atomic audit entries and post-commit
cache/Scout/observer effects. This is a logical partial change implemented
through
save(): a full-rowUPDATE, or, for a model with aRowSnapshotfield, anUPDATEof the patched columns whose value differs from the locked row (none: noUPDATE). Existing model hooks can transform that candidate under the normal save contract: the loaded row runsafter_fetchbefore the patch is merged, asfind()would return it, andrestore_revisionruns it on the restored row before saving. Both run inside the borrowed transaction, where reentrant ORM access fails closed. - The caller’s object is replaced with the fresh merged model only after the
operation succeeds. A direct save waits for its transaction commit; an
explicit or task-scoped save reflects the transaction’s tentative state, so
the caller must discard/reload it if the enclosing transaction rolls back.
A
PostCommitfailure does not undo a durable direct save. An ambiguous database commit error requires reconciliation rather than blind replay. - An empty builder remains a no-op. A failed policy, hook, SQL write or audit
rolls back the operation’s savepoint and discards its pending effects; if
that rollback fails too, the returned
DatabaseErrornames both errors. This allows a managed outer transaction to catch that failure and continue. Strict post-commit timing requiresOrm::transactionor the owned direct path; a raw SQLx transaction retains the documented observation limitation. - Unselected persisted values and relation fields in the caller’s object are refreshed from the database representation. Unsubmitted edits on that object do not become an implicit patch. This is not an expected-version comparison: callers needing conflict rejection must still supply their domain revision contract. Local SQLite/PostgreSQL/MySQL, cancellation, cache/Scout and extracted-facade journeys pass. Full hosted workspace/platform/package source admission passed in PR #236; final release admission remains separate.
5.5. Process-Local Post-Commit Contract
- A nested
Orm::transaction(called while another managed or task-scoped transaction is active on the same task) does not open a second pooled transaction. It opens a SQLx-tracked savepoint on the active transaction and passes the same shared handle to its closure. AnErrrolls back only to that savepoint and is returned unchanged; a success releases it, so the nested work commits or rolls back with the outer transaction. Itsafter_commitcallbacks are promoted to the outer commit boundary only on success. Dropping an unfinished nested future rolls its savepoint back. The returned future isSendforSendresults and errors, so it can be nested in a transaction closure or spawned. Holding the shared handle’s lock across a nested call deadlocks, as it does for generated model methods. - Savepoints on one connection close in LIFO order. Sibling nested
transactions started concurrently on one task take turns: each holds its
level’s turn from
SAVEPOINTuntil its release or rollback, while a transaction nested inside it uses its own level and never waits for it. Plain statements from a sibling future are not serialized and run inside whichever savepoint is open. A savepoint left open (a deeper level still open at release, a failed release/rollback, or a nested future cancelled while the connection was busy) never commits partial work: the enclosing savepoint rolls back instead of releasing, a managed transaction rolls back and returns an error instead of committing, and the ORM pool closes a connection returned while SQLx still reports an open transaction. - A generated
stream()running on a managed or task-scoped transaction keeps that transaction locked between rows until it is consumed or dropped. While it is open, every other generated ORM call,Orm::transactionandOutbox::enqueueon that transaction fails withValidationinstead of waiting for the lock (the query timeout does not cover that wait). Orm::transactionand direct generated modelsave()/delete()/restore()/force_delete()operations own a post-commit callback scope.after_commitcallbacks registered within it run only after SQLx confirms commit and are discarded on rollback. When no managed transaction is active,after_commitexecutes immediately for an already committed/autocommit operation.#[rullst_orm::test]runs its body in such a scope too and never commits it, so the observers, cache, Redis and Scout effects of sandboxed writes are discarded with the rollback.- Generated observers retain synchronous lifecycle callbacks such as
creating,created, andsavedfor mutation validation. The separatecommitted(ModelCommittedEvent)callback receives an owned snapshot after the managed commit; it omits hidden fields and carries"***"for encrypted and masked fields. Generated Redis invalidation/pub-sub and Scout projections use this same post-commit boundary. - Builders add
with_trashed()(include trashed rows) andonly_trashed()(only trashed rows). A model without soft deletes has no trashed rows, so itsonly_trashed()fails reads, counts, plucks anddelete_all()withValidationinstead of treating every live row as trashed; a directly setonly_trashedfield matches no row.with_trashed()changes nothing there. - Only
delete(),restore()andforce_delete()change the soft-delete marker of an existing row. Generatedsave()leaves that column out of itsUPDATE(anINSERTstill writes it), so a handle loaded beforedelete()cannot undelete the row and bypasscan_restore, therestoredaudit and the restore observers. An auditable save copies the stored marker into the handle before recording its diff, andupdate_partial()rejects a soft-delete value withValidation. force_delete()andrestore()check the tenant and their policy (can_force_delete/can_restore) before the transaction, then run in a savepoint.force_delete()runs thebefore_delete/after_deletehooks, thedeleting/deletedobservers and the same post-commit cache invalidation, Redisdeletedevent,committed(Deleted)and Scout removal asdelete(); it does not cascade tocascade_soft_deleterelations.restore()re-reads the restored row, runs theupdated/savedobservers with it and registers the update effects ofsave()(cache invalidation, Redisupdated/savedevents,committed(Updated), Scout re-index). It runs no save hooks orsaving/updatingobservers because it writes only the soft-delete column. ItsUPDATEmatches only a trashed row, so restoring a missing or live row is a no-op; likewise the soft-deleteUPDATEofdelete()matches only a live row, so deleting a trashed row again keeps its deletion time and fails like a missing row.- A
save()update,delete()orforce_delete()whose by-ID statement matches no row (for example a row deleted since its handle was loaded) fails withRecordNotFound, or with the tenant-scopeValidationerror on a tenant model, instead of reporting success; it rolls its savepoint back, so no post-mutation observer, hook, audit entry or post-commit effect runs.restore()of a missing row remains a no-op, also on tenant models. When the COMMIT of a directsave()(or the savepoint release ofsave_with_tx) fails, the handle’sidis restored like on any other failure, so a retry inserts again; an ambiguous commit error still needs reconciliation. - Savepoint-scoped generated saves/deletes and revision restores collect their
callbacks in a nested scope. The callbacks are promoted to the enclosing
commit boundary only after that savepoint succeeds, so catching a failed
mutation cannot leak a later
committedeffect. - Every
save_with_tx(and asave()joining a task-scoped transaction), not only an auditable one, runs in a savepoint of the caller’s transaction. A failed policy, hook, observer or SQL write rolls that savepoint back and restores the model’sid, so a caller that catches the error and commits does not persist the failed write. A directsave()owns its transaction and needs no savepoint. - Every queued callback is attempted. A failure is returned as
PostCommit, whose contract explicitly means the database mutation is already durable. Applications must not retry the database mutation blindly from this error. - A caller-owned raw SQLx transaction passed to
save_with_txordelete_with_txdoes not expose its later commit/rollback decision to the ORM. UseOrm::transactionfor the strict process-local boundary. - These callbacks do not survive process failure and provide no retry, idempotency or cross-node delivery. Use the explicit durable outbox below for an irreversible or externally delivered effect; it is not enabled automatically by a generated observer.
5.6. Durable Transactional Outbox Contract
Outbox::enqueueaccepts only a currently managedOrm::transactionand writesrullst_outboxthrough that same transaction (for a nested call, through the outer transaction’s savepoint). A domain rollback also removes the event.enqueue_with_txprovides the equivalent explicit path for a caller-owned SQLx transaction. No implicit independent commit is permitted.(stream, event_key)is the database uniqueness boundary. Replaying the same key, exact event kind and JSON-equal payload (object key order is not content) returns the existingi64identifier, including a key committed by a concurrent enqueue after the caller’s read snapshot (MySQL/MariaDB read it back with a lockingFOR UPDATEread, as InnoDB’s defaultREPEATABLE READwould hide it from a plainSELECT); reusing the key with different content fails closed with an error that does not echo the stream or key.stream, event key, event kind and worker identifiers use a bounded ASCII grammar, and serialized payloads are limited to one MiB. Streams, event keys and claim tokens compare case-sensitively on every backend; MySQL/MariaDB declare those columnsCHARACTER SET ascii COLLATE ascii_bin, andOutbox::installconverts an existing MySQL/MariaDB table whose key columns use another collation.- PostgreSQL, MySQL/MariaDB and SQLite share the outbox state machine. A claim
increments attempts and receives a random token plus a bounded lease. Only
that token may acknowledge or fail the event; expiration permits another
worker to reclaim it. Failure schedules a bounded retry or moves the event to
dead_letterat the configured attempt limit, including a worker that dies while holding its final lease.claim_next,acknowledgeandfailare independent lease operations that commit on their own connection; inside an activeOrm::transactionthey returnValidationinstead of committing before, and regardless of, the handler’s work. Acknowledge after that work has committed. - Delivery is at least once, not exactly once. A worker may perform its external effect and crash before acknowledgement, so consumers must use the stable stream/event key as their own idempotency key. Ordering across retries or concurrent workers is not guaranteed.
Outbox::installis an explicit setup/test convenience and never runs at startup.OutboxMigrationputs the same schema under the built-in reviewed migration lifecycle. The ORM does not infer tenant authorization fromstream, automatically serialize model observers, dispatch HTTP webhooks, purge delivered rows or promise cross-database transactions.
5.7. Generated Redis Query Cache Contract
- The optional
redisfeature enables.remember(seconds)for generated SQLx reads.Orm::init_redis_with_namespace(url, application_namespace)is the recommended initializer when a Redis database is shared; the compatibilityinit_redis(url)initializer uses the literal namespacedefault. - Versioned SHA-256 cache keys bind the validated application namespace, a
partition, table, generated SQL, and typed bindings. A model with a
tenant_columnpartitions its entries by an opaque digest of the active tenant scope (globaloutsidewith_tenant); any other model keeps oneglobalcopy whatever tenant scope its reads run in. Raw tenant identifiers are not emitted in keys. - Generated reads always bypass Redis inside explicit and task-scoped database
transactions, so cached state cannot replace the transaction’s own view.
remember(0)is invalid. Outside transactions, explicitly requesting cache without initializing Redis fails closed as a configuration error; transport failures and corrupt cached values fail open to the authoritative database, whose result replaces the entry. Only a JSON array whose rows all decode and decrypt is a hit:null, an object or a row whose encrypted orSecretStringfield no longer decrypts (for example after key retirement) is a miss, not an empty result or an error. - Cache writes occur only after a successful database read and retain encrypted
model fields as ciphertext;
SecretStringfields are cached as serde envelopes and decrypted on a cache hit, and a result that cannot be serialized (for example without an encryption key) is not cached. Each generated cache write also records its key in a per-namespace/tenant/table Redis sorted set, scored by the entry’s expiry time on the Redis server clock, in the sameEVALscript; that script first prunes members whose entries already expired and extends the set’s TTL to the longest entry TTL, so the index holds only entries stored within the last TTL window. Generated modelsave()/delete()/restore()/force_delete()operations invalidate the table’sglobalpartition and, for a tenant-scoped model, the partition of the tenant active at the write, only after commit (the tenant is captured when the callback is registered, so awith_tenantscope that ended inside the transaction closure still has its keys removed). Anunscoped()read of a tenant-scoped model inside another tenant’swith_tenantscope is cached in that scope’s partition and is not refreshed by other tenants’ writes before its TTL. Invalidation prunes expired members without counting them, then removes each index’s live members in batches of 500 andUNLINKs their keys (at most 10,000 per write); they neverSCANthe Redis keyspace, so their cost does not grow with unrelated keys in a shared database. Beyond the cap the write reportsPostCommit, the remaining keys stay indexed for the next write, and the generatedorm:events:*publication still happens. Keys are versioned (rullst:orm:cache:v4:); entries written by earlier versions are never read again and expire through their TTL, and during a rolling upgrade a write from an instance on the other version does not invalidate this version’s entries before their TTL. A transaction rollback preserves existing entries. The scripts address keys they were not passed and are therefore outside Redis Cluster, like the rest of this contract. Raw SQL, bulk builders, caller-owned raw transactions and writes from other processes cannot be inferred. Callers must retain a defensive TTL and treat Redis cluster/failover and durable invalidation delivery as separate application contracts. - Generated
save_to_redis/get_from_redis/increment_redis_fieldmodel hashes userullst:orm:hash:v1:<namespace>:<scope>:table-<digest>:<id>keys, binding the application namespace, an opaque digest of the model’s tenant (orglobal) and of the table. For a tenant model all three requirewith_tenant(...)with the tenant field’s type,save_to_redisrejects a handle from another tenant,get_from_redisrejects a decoded hash whose tenant differs and the tenant column cannot be incremented; another tenant simply misses. 12.0 and 12.1 stored every hash under the shared keyorm:<table>:<id>. For a model without a tenant scope,get_from_redisreads that key when the namespaced hash is missing, and the nextsave_to_redis/increment_redis_fieldatomically moves it to the namespaced key (an existing namespaced hash wins and the stale legacy hash is removed), so data migrates lazily. A tenant model never reads, moves or deletes the legacy key, which every tenant shared; its hashes are migrated explicitly (see the Redis guide). Theorm:events:*pub/sub channel names are unchanged and not namespaced, so subscribers keep working; use a dedicated Redis database when applications must not observe each other’s events.
5.8. Polyglot Persistence Boundary
- Optional persistence adapters are disabled by default and selected one at a
time with
mongodb,duckdb,turso,surrealdborqdrant; the umbrella crate exposes matchingorm-*features. 13.0 removed thepolyglotandorm-polyglotaggregates, which enabled every adapter at once, including DuckDB’s bundled C++ build. - Only the SQLx backends (SQLite, PostgreSQL, MySQL/MariaDB) integrate with the
#[derive(Orm)]query builder,Schema,artisan!/db:*,Server::with_db, Nexus and Studio’s database pages, and everycargo rullst newstarter accepts them. Turso/libSQL is the primary database of the Blank starter only:#[orm(backend = "turso")]derives the separateTursoModelAPI, migrations are SQLTursoMigrations, the starter answersdb:migrate/db:rollback/db:statusitself andTursoStore::transactionruns atomic statement batches; it integrates with none of the SQLx parts above. MongoDB, DuckDB, SurrealDB, Qdrant and Redis data structures are side stores with their own APIs and no transactions.cargo rullst newstates these limits next to the Turso and side-store choices in the wizard, in--helpand on the review/dry-run screen. Live CI contracts arematrix_postgres,matrix_mysql,matrix_mariadb,matrix_turso,matrix_mongodb,matrix_surrealdb,matrix_qdrantandmatrix_redis_data; SQLite and DuckDB run in-process. The Database Support table is the user-facing statement of this boundary and must change with it. DocumentRepository<T>provides create, find, replace, delete, and deterministic bounded listing. Collection names, document IDs, offsets and limits are validated before reaching a driver.DocumentInventory<T>is the separate identifier-preserving extension used by recovery tooling. Its stable ascending pages avoid breaking existing third-partyDocumentRepositoryimplementations.export_document_snapshotperforms two matching bounded observations before sealing a versioned payload with AES-256-GCM. The authentication data binds the key-rotation ID, trusted application namespace and exact collection; decoding is capped at 64 MiB and 100,000 documents. Restoration accepts only an empty destination or an exact matching subset, never replaces or deletes, tolerates only an exact raced duplicate and verifies the complete final inventory. Applications must quiesce source/destination writers and durably store, rotate and protect keys/snapshots. Required destination schema must be provisioned first; driver/schema errors never become an implicit empty collection. The API is crash-resumable, not a cross-store transaction or managed backup service.MongoDbStore<T>uses the official MongoDB Rust driver and stores the portableDocumentIdas_id; portable models must not define_id.DuckDbStoreserializes access to its native connection and delegates every database operation tospawn_blocking. Dynamic values use prepared parameters, and callers must supply aQueryLimitbefore rows are materialized. Application-provided SQL text remains a trusted structural input.TursoStorespeaks the official Hrana HTTP v3 protocol directly for remote edge SQL. It uses positional typed parameters, conditional atomic batches, a 30-second request deadline, no redirects, a 16-MiB response bound, bounded row materialization, and ordered checksummed migrations. This avoids an unnecessary embedded/native SDK dependency while retaining conformance against the official libSQL server. Empty ormock_*endpoints use a one-connection SQLite fallback that exercises real SQL without pretending to be a remote replica. HTTPS/libsql://is required outside explicitly enabled loopback development.SurrealDbStore<T>uses the documented/key,/sql,/rpcand/gqlHTTP endpoints with namespace/database headers (replaceis a non-creatingUPDATEthrough/rpc, and document IDs are always string keys), no redirects, bounded streaming responses, HTTPS by default, and redacted authentication configuration.GraphQuery::read_onlyaccepts oneMATCHquery, rejects mutation tokens and caller-supplied limits, then appends a bounded limit.- This boundary does not turn every backend into SQL Active Record, perform cross-database transactions, provide an online-consistent snapshot, synchronize records between engines, or prove a third-party deployment. See the Polyglot Persistence guide.
5.9. Scout Search Projection Contract
- Without a configured engine,
Model::search(query)falls back toCAST(column AS TEXT) LIKE ? ESCAPE '!'over the persisted columns except#[orm(hidden)],#[orm(encrypted)],#[orm(masked)]andSecretStringones, so it cannot become a substring oracle for them.%,_and!in the query match literally, and the query uses the provider bounds (1,024 bytes, no control characters). #[orm(searchable)]projects generated save/delete operations only after a managed relational commit. The table name is the Scout index, so a searchable model whose (explicit or default) table name does not start with a lowercase ASCII letter followed by lowercase letters, digits or underscores fails compilation instead of failing every projection. The indexed document omits#[orm(hidden)],#[orm(encrypted)]and#[orm(masked)]fields. Search adapter failures remain visible; a failed query is not silently treated as an empty result, andPostCommitmeans a projection failed after the database mutation became durable.MockSearchEngineis deterministic and always available. The optionalscout-httpfeature adds Meilisearch, Elasticsearch and Algolia. Empty ormock_*credentials select the mock; keyless live constructors accept only loopback HTTP, while remote/custom origins require HTTPS without URL credentials, redirects, paths, queries or fragments.- A provider answers one shared per-table index and returns at most
scout::MAX_SEARCH_HITS(1,000) IDs before tenant, model-wide and soft-delete scopes filter them. For a model with any of those scopes, an answer of that size is treated as truncated:search()answers from the SQL fallback instead, so one tenant’s hits can never push another tenant’s matches out of the result. Below the cap the scoped provider IDs are used; engine-side tenant filtering is not provided. - Provider matches keep the provider’s ranking:
search()orders them with anORDER BY CASE <table>.id WHEN ? THEN 0 ...over the bound IDs, soget(),first(),paginate()andpluck_*follow relevance. An explicitorder_byreplaces that order, DISTINCT and GROUP BY statements omit it, anddelete_all()does not treat it as a rejectedorder_by(). The SQL fallback has no relevance order. - Index names, positive IDs, object payloads, queries, response bytes and hit
counts are bounded. Meilisearch/Algolia tasks use bounded polling;
Elasticsearch requests use
refresh=wait_for. Provider response bodies and credentials are not copied into transport errors. - The repository proves a real Meilisearch lifecycle in a digest-pinned container. Elasticsearch and Algolia protocol fixtures prove the documented HTTP shape and bounds, not hosted service operation, version-wide compatibility, ranking quality or cluster failover.
- The generated hook remains process-local. Guaranteed crash recovery requires
an application-versioned event in the transactional
Outboxand an idempotent worker; the ORM cannot infer a safe event key or external retry policy from an arbitrary model save.
5.10. PostgreSQL pgvector Contract
- The optional
pgvectorfeature re-exports the SQLx-compatibleVectortype. The supported execution profile combines it withstrict-postgres; other SQLx backends do not pretend to implement PostgreSQL vector operators. where_similar, L2, cosine and inner-product ordering validate column names, reject empty/non-finite vectors and invalid distances, and bind vector and distance values. ORDER BY bindings are assembled after WHERE bindings regardless of builder call order.- A digest-pinned PostgreSQL + pgvector container installs the extension, uses a typed vector model and proves L2 threshold/cosine ordering queries. The application still owns reviewed migrations, vector dimensions, embedding model compatibility, HNSW/IVFFlat index selection/tuning, tenant policy, context budgets, citations, ingestion/deletion and RAG evaluation.
5.11. Qdrant and Redis Specialized Store Contract
- The optional
qdrantfeature exposes a separateVectorRepositoryrather than pretending Qdrant is SQL Active Record. Collection names, dimensions, vectors, cosine norm, point payloads, query limits and response bytes are bounded. The HTTP client rejects redirects and URL credentials, uses short connect/request deadlines, requires HTTPS outside loopback, redacts API keys, and never copies provider response bodies into errors. QdrantConfig::newselects a deterministic in-process fallback for empty ormock_*endpoint/API-key values.unauthenticated_localis an explicit loopback-only path for self-hosted development. The supported live API is one unnamed dense cosine vector per numeric point with create, single-point upsert/delete and bounded nearest-neighbor query; named/sparse/multivectors, arbitrary filters, collection tuning and distributed topology are outside it.- The optional
redisfeature exposesRedisDataStorefor explicitly namespaced Hash, Set and Sorted Set operations in addition to the generated query cache. Keys, fields, UTF-8 values/members, finite scores and scan/range materialization are bounded. Remote endpoints requirerediss://; URL credentials are rejected, ACL credentials are redacted, operations have connection/response deadlines, and empty/mock_*credentials select a deterministic in-process fallback. - Digest-pinned Qdrant and Redis matrices prove their respective live lifecycles, including Redis namespace/structure separation. They do not prove hosted-provider availability, backups, cluster failover, tenant authorization, eviction policy, ANN quality, or cross-store transactions.
ORM Driver Selection
Orm::driver()reports the SQL dialect from the connected DSN scheme, case-insensitively:postgres/postgresqlselect PostgreSQL andmysql/mariadbselect MySQL/MariaDB, so dialect-gated SQL such asFOR UPDATErow locks, quoting and outbox/migration DDL follows the server. Any other scheme keeps the SQLite dialect (an unknown scheme fails to connect under SQLxAny).- A DSN that still contains a bracketed template placeholder such as
[your-database-id]or[YOUR-PASSWORD]failsOrm::init,init_with_optionsandinit_with_replicas(primary or any replica) withError::Internalbefore connecting. A bracketed IPv6 literal host (postgres://app@[2001:db8::10]:5432/app, optionally with a%25zone identifier) is not a placeholder and is accepted by every entrypoint. - An in-memory SQLite DSN (
sqlite::memory:ormode=memory) exists only while one of its connections is open, so its pool keeps one connection and never closes it for idleness or age (the Turso offline in-memory fallback does the same). - A SQLite file DSN without a
modeparameter, or withmode=rwc, has its missing database file (and directory) created before connecting, at the percent-decoded path SQLx opens (John%20DoeisJohn Doe). An explicitmode=roormode=rwnever creates one, so a wrong or unmounted path fails to open instead of becoming a new empty database. - Since 13.0.0-alpha.3 neither
rullst-ormnor therullstfacade has a default feature (they weredrivers-all, andorm,drivers-allandqueue-sqlite), so a database-free application compiles no SQLx driver. A consumer selectsstrict-postgres,strict-mysqlorstrict-sqlite, each enabling only its own SQLx backend, or thedrivers-allconvenience feature for all three. On theAnypool,Orm::init,init_with_optionsandinit_with_replicasturn SQLx’sno driver found for URL schemeerror for apostgres/postgresql,mysql/mariadborsqliteURL intoError::Validationnaming thestrict-*feature (ordrivers-all) that adds the driver; other connection errors are unchanged. The strict pool’s existing precedence when multiple strict features are unified remains PostgreSQL, then MySQL, then SQLite. - Features are additive. Another dependency enabling ORM defaults, a SQLite
queue, Turso’s offline SQLite transport or another SQLx driver can broaden the
final graph. Core, Studio, Nexus and the facade propagate an explicitly
selected backend when defaults are disabled. Studio’s SQLite queue is now
explicitly optional; Studio’s defaults retain
drivers-allandqueue-sqlite, and Nexus’sdrivers-all. Fifteen standalone normal/build graphs cover ORM/Core/Studio/Nexus/facade across all three SQLx backends. Capital/other optional capabilities may intentionally broaden those graphs. Turso enables SQLite for its offline contract. - A standalone consumer check must compile generated model/query/transaction
code and inspect its normal/build graph for unrelated SQLx driver packages.
Workspace all-feature or all-target checks cannot prove driver isolation.
Applications previously disabling defaults without selecting a driver must
choose a strict backend or explicitly restore
drivers-all. - Native enum codecs follow the ORM’s selected drivers, not identically named
features in the consuming application. ORM 12.1 opts its paired macro crate
into
runtime-driver-codecsand exports hidden, driver-gated codec helpers. The macro crate’s default expansion remains compatible with the all-driver 12.0 runtime; the opt-in helper expansion requires the matching 12.1 runtime. Standalone isolation checks include enum encoding/decoding as well as CRUD. - Optional generated APIs likewise follow the ORM’s features, never features
of the consuming application. With
rullst-orm/redis, models get.remember(...), commit-time query-cache invalidation, theorm:events:*publications and the Redis hash helpers; withrullst-orm/ai(which impliespgvector),embedding_formodels getsave_with_embedding. ORM 13 opts its macro crate intoruntime-feature-gatesand forwards both features, so the choice is made at expansion time. Without that opt-in (an older runtime resolving a newer macro crate) the legacy output keeps its application-evaluated#[cfg(feature = ...)]attributes.
5.12. ORM Telemetry Contract
- Generated model/query entrypoints, transaction-aware variants, raw ORM
queries and generated streams emit
rullst.orm.queryspans with only a static model, validated table and bounded operation name. SQL text, bindings, model values, DSNs and error strings are not fields of these Rullst-owned spans. The explicit debug query logger remains a separate opt-in surface. - Managed transactions emit begin and lifecycle spans. Their final outcome is one of the bounded commit/rollback states; transaction errors are returned to the caller rather than copied into telemetry. Generated stream spans are entered only while the stream is polled, so a tracing guard is never held across suspension.
- Every pool constructed through
Orm::init*emits SQLx pool-acquire timing at info level and promotes acquisitions slower than 500 ms to warnings. Primary and replica pools share this configuration. Direct pools constructed by the application are outside the contract. - These standard
tracingspans/events are exported when the host enables the umbrellatelemetryfeature and initializes Core’s OpenTelemetry subscriber. The host still owns OTLP endpoint security, filters, sampling, retention and collector availability. SQLx or application logging configured separately may have its own statement-data policy.
💳 6. Billing & Payments (rullst-capital)
rullst-capital exposes provider-neutral billing contracts, a supported Stripe
adapter and an experimental InfinitePay adapter. Applications integrate any
other gateway by implementing the same traits in their own code; see
Writing your own payment provider.
Every reviewed live adapter uses the same fail-closed outbound HTTP boundary:
redirects and ambient proxy environment variables are disabled, connection and
whole-request timeouts are finite, and a successful provider response is read
only up to one MiB before JSON decoding. Failures expose a redacted typed
ProviderFailure contract with permanent, transient, and rate-limited classes;
only a bounded numeric Retry-After delta is retained. Raw response bodies,
request URLs, credentials, and transport diagnostics are not included in the
public error. Rullst deliberately does not retry billing mutations: callers may
retry a transient or rate-limited result only when that exact operation has a
persisted provider-forwarded idempotency key and a reconciliation policy.
Returned checkout locations are accepted only as bounded, absolute,
credential-free HTTPS URLs. Offline legacy checkout and portal fixtures use
reserved mock.<provider>.invalid hosts and omit the customer email and
return URL. Stripe’s documented opaque hosted-URL fragment is
preserved; other adapters reject fragments. Provider/account sandbox acceptance
remains external evidence.
6.1. Provider Architecture
Billing adapters implement BillingProvider; metered usage adapters implement
MeteredBillingProvider with a provider-specific request type. Rullst ships two
adapters: Stripe, the supported provider used by the generated billing flow, and
InfinitePay, which is experimental until it is validated against a live
account (its live plan-only checkout and live callbacks fail closed). The trait
default for webhook verification mode fails closed, so a custom adapter must
declare its verification mode before Rullst’s webhook middleware accepts it.
Individual billing operations may still return Unsupported when a provider
adapter has no reviewed implementation:
#![allow(unused)]
fn main() {
use rullst_capital::providers::stripe::StripeProvider;
use rullst_capital::providers::BillingProvider;
async fn create_checkout() -> Result<(), rullst_capital::CapitalError> {
let provider = StripeProvider::new("mock_api_key", "mock_webhook_secret");
let session = provider
.create_checkout_session(
"[email protected]",
"price_monthly",
"https://example.com/billing/complete",
)
.await?;
let _ = session;
Ok(())
}
}
#[derive(rullst::Billable)] is the umbrella convenience for named structs with
an email: String field. It preserves generics; optional
subscription_id: Option<String> and tier: Option<String> fields expose the
corresponding helpers. An all-or-none
grace_period_starts_at: Option<i64>/grace_period_ends_at: Option<i64> pair
exposes a validated half-open window of at most 366 days. A provider-bound
SubscriptionHandle<P> delegates cancellation and pausing; the explicit
subscription_with path keeps static dispatch. These values do not infer or
persist ownership, team membership, entitlement, currency, payment methods,
usage or provider scheduling. Shared quota accounting is a separate explicit
boundary described below.
The same derive inherits the bounded charge_with/charge helpers for an
immediate off-session charge. A charge requires a positive integer amount in
currency minor units (maximum eight digits), a three-letter currency, explicit
provider customer and tokenized payment-method IDs, the model e-mail and an
application-owned idempotency key of at most 255 bytes. BillingProvider::charge
defaults to UnsupportedOperation; the reviewed live implementation is Stripe
Payment Intents, which forwards the idempotency key, confirms off-session and
fails closed on an amount/currency mismatch or a status other than succeeded
or processing. Empty/mock_* Stripe credentials return a deterministic local
receipt with the distinct non-success Mock status for exact retries. Rullst
does not model raw payment credentials, prove that a stored method has a valid
mandate, persist idempotency, grant an entitlement, reconcile webhooks or imply
direct-charge parity across adapters.
Customer-bound Stripe Subscription Checkout
StripeCustomerRequest and StripeProvider::create_customer supply the
preceding provider-customer operation. The immutable input binds an opaque
local owner reference, a persisted retry key and optional bounded contact
email. The response must be an undeleted customer with matching metadata,
valid ID/time and the credential’s test/live mode when that mode is known.
Mock receipts are separate from provider creation. Requests carry a versioned
digest, explicit idempotency header and the pinned checkout API version.
The host must persist provisioning intent before HTTP, bind the result to the
authorized account/owner before checkout, and reconcile unknown outcomes;
provider idempotency retention is not durable application state. Email is not
used to find or establish ownership of an existing provider customer.
The additive StripeCheckoutRequest binds an existing provider customer, one
server-owned recurring price, an opaque local owner reference, explicit HTTPS
success/cancel URLs and an application-persisted idempotency key. The dedicated
Stripe method sends that key and reference with the customer ID; it does not
create or discover a customer by email. A bounded response must match the
customer, owner reference, requested price/quantity, subscription mode and
redirects before a checkout session is returned. Request/receipt debug output
omits identifiers, URLs and keys. Empty/mock_* credentials produce an explicit
deterministic mock, never payment evidence.
The application must commit customer/tenant ownership and an immutable attempt with its request digest before dispatch, retain account/test-live namespaces, and reconcile unknown outcomes. Provider idempotency has a finite retention window; repeating an expired key is not a durable deduplication guarantee. Session creation and return navigation never grant paid access. The legacy email-based trait method remains source-compatible; new generated Stripe flows use the dedicated customer-bound contract and durable persistence below.
StripeSubscriptionLookup and StripeProvider::retrieve_subscription provide
an explicit read for reconciliation. The request binds subscription, persisted
customer/local reference, expected price and test/live mode; the pinned-version
response must match every binding and the bounded single-item state contract.
StripeSubscriptionSnapshot preserves the exact provider status and separates
retrieved state from deterministic non-entitled mocks. A retrieved snapshot is
neither an event claim nor invoice-settlement evidence. Hosts must serialize
reconciliation with their database update so a delayed earlier read cannot
overwrite a newer state; fetching before an unrelated transaction does not
establish event order. Account credential custody and durable intent remain
application responsibilities.
Provider-Specific Metered Usage
MeteredBillingProvider deliberately uses an associated request type rather
than pretending provider identities and retry semantics are interchangeable.
StripeMeterEvent targets the current /v1/billing/meter_events API with the
default stripe_customer_id and value payload mapping. It validates positive
integer usage, the meter event name, customer, timestamp window and a forwarded
identifier; the Stripe adapter sends that identifier as both event identity and
HTTP idempotency key.
The adapter limits response JSON to one MiB, binds identity/quantity fields
before returning UsageStatus::Accepted, redacts request/receipt identities
from Debug, and returns deterministic Mock receipts for empty or mock_*
API keys. Stripe documents only a rolling provider deduplication window. A
custom adapter whose provider has no equivalent event-key field reports
UsageDeduplication::ApplicationOutboxRequired; the application must then
claim event_key durably before submission. Provider-account acceptance,
durable outbox storage, retries, reconciliation and entitlement/quota policy
remain application/release evidence. The legacy uniform
BillingProvider::report_usage is compatibility-only and fails closed for live
Stripe rather than guessing the required provider-specific identity.
Coupons and Relative Trial Extensions
CouponCode accepts at most 256 ASCII identifier bytes and redacts its value
from Debug. The Stripe adapter uses discounts[0][coupon], requests expanded
discount evidence and accepts only a response bound to both the subscription
and requested coupon. InfinitePay and every adapter without a reviewed live
contract return UnsupportedOperation rather than a false success. Empty
or mock_* credentials retain the deterministic offline no-op required for
local applications and tests.
TrialExtension resolves 1 to 730 whole days against a trusted clock.
Billable and SubscriptionHandle expose the historical ergonomic
extend_trial(15) meaning plus extend_trial_days_at for a stable persisted
command clock and set_trial_end for explicit reconciliation. Stripe sends
trial_end, caps the provider response and binds the returned subscription and
expiration.
The host must authorize the subscription owner, persist a stable command time
before retry, serialize conflicting changes, reconcile signed webhooks and
evaluate provider-specific billing-cycle effects. Live-account acceptance is
release evidence, not inferred from protocol fixtures.
Server-side Plan Entitlements (v13 candidate)
The candidate rullst-capital::entitlements module supplies a read-time gate,
separate from usage quotas. EntitlementScope binds an authenticated tenant to
an existing BillingSubject; EntitlementPolicy binds one server-selected
feature to at most 64 exact plan IDs, an explicit live/sandbox mode and a
1–300 second maximum reconciliation age. Only active subscriptions qualify;
trial, past-due, canceled, unknown and mock states deny access. Validity has an
exclusive end, and both future observations and clock rollback during a read
are rejected. No serialized snapshot or browser field is an authorization token.
EntitlementStore is a static-dispatch trusted adapter contract. Every gate
call reads it again and checks the returned tenant/subject binding, provenance,
plan, status, expiration and trusted clock before and after the read. Store
failures deny access. Hosts must supply authoritative current state and serialize
reconciliation/revocation; this read gate does not make subsequent domain writes
atomic or revoke an operation already authorized. Custom adapters and database
backup/restore remain explicit application obligations.
The generated SaaS candidate consumes this gate at an authenticated billing report route. Its single-tenant scope is the configured Stripe account/mode inside the application’s database, never a request header or submitted owner. The existing durable billing intent and revision fence precede an actual ownership-bound provider refresh; the state is committed before authorization. It performs this refresh on every report, with a bounded deadline and no cached grant. The exact report-plan allowlist is separately configured on the server. Production requires live state; local sandbox state requires the explicit test profile, and offline credentials produce deterministic denial. Mutable CMS subscription projections, checkout redirects and unverified events cannot grant report access. Other providers need reviewed reconciliation adapters before this consumer can enable them. Existing profile/rights exports remain independent.
The local implementation and generated HTTP/database contracts exercise current state, account/owner isolation, revocation races, expiry, provider/store failures, mock denial and cancellation at the request deadline. Hosted source admission passed in PR #221; final release admission remains separate, and protocol fixtures do not establish live provider-account acceptance.
Shared Team and Workspace Quotas
BillingSubject identifies one authoritative user, team, workspace or trusted
tenant as the owner of both the subscription and its shared counters.
Billable::quota_request derives the limit from that owner’s tier policy instead
of accepting it from an HTTP payload. A QuotaStore then performs an atomic,
idempotent reservation before the application creates the resource.
InMemoryQuotaStore is the deterministic offline/process-local contract. The
opt-in quota-sql feature supplies SqlQuotaStore for SQLite, PostgreSQL,
MySQL and MariaDB. Its conditional counter update and unique event claim prevent
concurrent members from exceeding the same limit. Exact retries return a replay
grant without consuming or executing again; a key reused with different units
or limit fails closed. Subjects, features and event keys are case-sensitive on
every backend: MySQL/MariaDB key columns use ascii_bin, and the store refuses
a legacy table whose key columns fold case until its documented migration runs.
A replay proves only that the key is claimed, not that
the guarded work finished: the claim may belong to an in-flight call that later
fails and releases it, or to an abandoned call. QuotaGate::execute blocks the callback before an
over-limit creation and compensates an ordinary callback error.
The convenience gate cannot make two unrelated storage systems atomic. A
relational application that needs exact quota/resource atomicity must open a
transaction from SqlQuotaStore::pool, call reserve_with_transaction, perform
the domain insert through that transaction and commit once. Trusted middleware
must establish membership and active tenant before constructing the subject.
Plan/webhook reconciliation, migrations and custom/non-relational stores remain
explicit application work.
6.2. Invoice Rendering
Invoice::generate_html remains the source-compatible escaped HTML renderer.
Trusted paths use validate/try_generate_html: the legacy public f64 model
accepts only bounded finite positive values with no more decimals than the
currency’s ISO 4217 minor unit (none for JPY, three for KWD, two by default),
converts them to 1 to 99,999,999 integer minor units of that currency, and
requires the exact item sum. HTML and PDF amounts use the same number of decimals, so the minor
units match a provider receipt for zero- and three-decimal currencies too.
The opt-in invoice-pdf feature adds bounded paginated A4 rendering. Its
embedded Helvetica subset supports WinAnsi text; other scripts require a
caller-supplied TTF/OTF of at most eight MiB containing every used glyph. PDF
output is capped at sixteen MiB. Invoice::bind_succeeded_charge creates an
immutable PaidInvoice only when a final, non-mock receipt exactly matches the
recipient, minor-unit amount and currency. It derives a stable non-secret key
from the invoice and provider evidence for use by an application outbox.
The downstream rullst-mail/capital-invoice feature converts that value into a
pipeline-validated HTML message with the PDF attached and exposes one-call
facade, tenant-aware or static-driver delivery. It does not infer a webhook
event, atomically claim the delivery key, guarantee provider acceptance or
promise exactly-once delivery. Applications processing retries or multiple
instances must persist/claim the key and reconcile payment state durably before
sending.
6.3. Webhook Signature Verification
- The experimental InfinitePay adapter has a body-only verifier but no reviewed
authentication and authoritative payment-lookup contract for the documented
checkout callback.
Real-secret verification and normalization return
UnsupportedOperation; explicit mock-secret fixtures remain offline-only. A local HMAC fixture is not evidence that the provider signs that protocol. Enabling live processing requires order/merchant/amount binding and provider reconciliation first. - The additive
StripeProvider::verify_subscription_eventreturns an immutableStripeSubscriptionEventafter the existing signature/freshness check and bounded subscription normalization. It retains event ID/type/API version, creation time, matching event/subscription test-live mode, optional connected account and opaque local owner reference, plus the original provider status. A versioned mutation digest excludes contact email, delivery signature and unrelated JSON fields; a separate digest binds the exact raw payload. Neither digest is encryption or a persisted replay claim. Mock verification is explicit in the result andrequire_realrejects it. The host must bind the endpoint’s provider account, mode, customer and owner, and atomically commit the event claim with domain writes. Event creation time is not a complete ordering/reconciliation protocol. The legacyWebhookEventandBillingProvidercontracts are unchanged. - Stripe subscription normalization requires an explicit supported lifecycle
event and subscription object with bounded subscription/customer/price IDs.
Its single-price v12 contract rejects multiple or truncated item lists and
statuses outside Stripe’s subscription vocabulary.
incompleteandincomplete_expiredmap to the legacy non-entitledUnpaidstatus; they must not be interpreted as proof of an unpaid invoice. Billing-period end is read from the single item for Basil payloads, or from the subscription on older payloads; conflicting period values are rejected. Email remains optional contact data. Event identity, ordering and atomic inbox processing remain separate requirements; an active subscription is not settlement evidence. - The Axum and opt-in Actix middleware adapters call one canonical bounded verifier before dispatch. Built-in provider adapters that accept live deliveries use provider-appropriate cryptographic verification; legacy parsers without a reviewed live contract fail closed. Equality checks for derived signatures are constant-time where applicable.
- Timestamped protocols enforce a bounded freshness window. The default replay
store is bounded and process-local and fails closed instead of evicting an
unexpired proof when full. It holds at most 10,000 proofs for 24 hours each,
so the default middleware answers 503 after about 10,000 verified deliveries
per rolling day in one process;
verify_webhook_with_stateaccepts a sized or shared store. - A custom adapter for a provider that signs only the body checks no delivery timestamp (provider retries can span hours or days). An exact captured body therefore verifies again after its replay claim expires (24 hours by default, at most 30 days) or, with the process-local store, after a restart. Hosts must record the provider’s event or subscription state durably and reject stale or repeated transitions instead of relying on the replay window alone.
- The opt-in
webhook-sqlstore shares bounded payload-digest or semantic-event claims across processes on SQLite, PostgreSQL, MySQL, and MariaDB. Its schema profile is immutable, claims serialize through one configuration lock, expiry uses the database transaction clock, and storage/configuration/capacity failures reject the request. MySQL/MariaDB claims use a plainINSERTwhose duplicate-key error is a replay, so a caller-owned REPEATABLE READ transaction whose snapshot predates a concurrently committed claim cannot re-claim it. - SQL-backed middleware admission claims a payload before handler dispatch; it
is replay protection, not an exactly-once delivery protocol. When billing
correctness requires atomic domain mutation, the application must verify the
provider payload, select the provider’s stable event ID, and call
check_and_record_event_key_with_transactionthrough the same relational transaction as the mutation. Cross-system effects still require an outbox, idempotent consumers, and reconciliation. - The additive
SqlStripeEventInboxunderwebhook-sqlowns the relational transaction for a verified subscription event and its caller-supplied SQL mutation. An immutable scope binds application namespace, configured Stripe account, endpoint kind and test/live mode. Mock or mismatched events fail before database access. A per-scope configuration lock serializes admission; the stable event ID and versioned mutation digest distinguish an exact retry from conflicting content. A committed retry returns the retained outcome without invoking the mutation again. Domain errors and cancellation before commit roll back both changes; uncertain commit requires replaying the same event to discover the retained outcome. Capacity is immutable and bounded; records are never automatically evicted. The host owns schema migration, retention/reconciliation, account credential custody, authorized customer binding, ordering and an outbox for external effects. No SQL transaction can undo HTTP or other external effects performed by the callback. This API does not migrate the generated handler or grant access from subscription status. - Generated SQLx and Turso billing persists an opaque authenticated-owner binding,
immutable customer/checkout intents and session IDs before redirect. Account
identity is checked through Stripe and test/live namespaces are separate.
Customer email never discovers or authorizes ownership. Stripe is the only
generated provider:
BILLING_PROVIDERaccepts onlystripe, and any other value is a configuration error before I/O. Real Stripe credentials require an explicit account, HTTPS return URL, webhook secret and price allowlist; mixed credentials remain unavailable. Customer and session creation reuse persisted keys for at most 23 hours. Older unknown outcomes use bounded read-only recovery; absence never permits another mutation. Completed and expired Checkout notifications and subscription lifecycle events trigger ownership-bound provider reads. A random database revision is committed before reading and compared atomically when committing the resulting subscription projection and durable event receipt. Concurrent newer reads fence earlier work; failed/uncertain commits remain retryable. An open checkout is retrieved and reused, and rejected/repeated requests do not consume a newly created-session quota. Existing subscriptions use an ID-bound customer portal. Provider-portal price changes must remain in the application’s allowed catalog. The application owns entitlement/settlement policy, inbox retention and capacity operations; a subscription state is not invoice settlement evidence. Existing application-owned code requires merging the generated modules and an additive migration; updating the package does not rewrite deployed controllers. - Hosted checkout forms require an explicit provider-specific CSP
form-actionorigin on the document that submits the form, including its HTTP 303 handoff. The SaaS starter selects Stripe and adds onlyhttps://checkout.stripe.comto its generated policy; Core’s default remainsform-action 'self'. Replacing Stripe with an application-owned adapter requires reviewing that exact checkout origin, including any merchant/custom domain.make:billingadvises this integration without overwriting an existing application’s policy. This is a browser policy boundary, not provider URL authentication: real flows must independently validate HTTPS, exact host/port, absence of credentials and durable session, customer, tenant, product and test/live bindings before emitting a redirect. A real-browser POST/303 positive and disallowed-origin negative are required; HTTP-client status checks alone cannot establish this behavior. - Before the future generated live flow creates a checkout, resolve the
authenticated owner’s persisted attempt. Resume only a provider-retrieved
open session with all the bindings above; completed, expired and uncertain
attempts need distinct handling. A business new-session quota applies only
to new attempts, with a useful
Retry-Afteron rejection; a separate request abuse limit may still protect retrieval. Concurrent clicks must converge on one durable intent/idempotency key. A disabled submit button is only feedback, never the duplicate-payment or ownership boundary.
6.4. Removed in v13
v13 narrows Capital to its provider-neutral base, Stripe and the experimental
InfinitePay adapter. The Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago,
Alipay, Coinbase Commerce and PicPay adapters, the Wise payout adapter and the
payout contracts (PayoutProvider, PayoutStatus, PayoutEvent and the global
payout registry) were removed, together with the Lemon Squeezy usage types and
the generated Paddle billing candidate. The NFS-e Nacional preparation pipeline
(the fiscal module, the nfse and facade capital-nfse features,
Invoice::to_dps and CapitalError::FiscalError) was also removed: it was never
validated with a real municipality, and it may return as a separate product
outside Rullst. The code remains in Git history. Applications implement other
gateways with the traits above (Writing your own payment
provider); the
migration guide lists the affected APIs, and
cargo rullst upgrade reports them as V13-CAPITAL-REMOVED.
🛡️ 7. Enterprise Security, RASP & Vault (rullst-security)
7.1. Rullst Vault (Authenticated Field Encryption)
- Algorithm: AES-256-GCM with authenticated 96-bit random nonces and 128-bit authentication tags.
- Envelope Format:
RULLST:v2:<key_id>:<base64_nonce>:<base64_ciphertext_and_tag>. - Key Rotation: Built-in keyring support (
decrypt_with_keyring) can read prior keys while new writes use the active key. Deployment coordination, re-encryption, key custody and retirement remain operator responsibilities. - ORM Configuration:
RULLST_ENCRYPTION_KEY,RULLST_ENCRYPTION_KEY_ID, andRULLST_ENCRYPTION_KEYRINGselect the current and still-readable prior keys. Each is read from the process environment first and then from./.env(v13), the precedence ofAPP_KEYandrullst::config::project_setting; the file never overrides or enters the process environment, and a malformed or unreadable.envfails the lookup with an error that names at most the entry number. Versioned envelopes name their key ID; pre-v12SecretStringciphertext names none, so it is tried with the current key and then every keyring key (defaultfirst), and AES-GCM authentication accepts only the key that wrote it. Rullst does not provide key custody or automatic retirement.
7.2. Runtime Application Self-Protection (RASP)
- Bounded Heuristic Inspector: ASCII case-insensitive signature matching covers selected SQL injection, traversal, SSRF, shell/JNDI patterns across URI, non-secret headers, and supported bounded textual/JSON bodies. Header values are decoded lossily in RASP and Core’s WAF, so an obs-text byte (0x80-0xFF) that hyper accepts cannot hide the rest of a value. Core’s WAF and this inspector classify body media types case-insensitively, including
+json/+xmlsuffixes and everyapplication/x-www-form-urlencoded-prefixed type, so a body that axum’sJsonorFormextractor accepts is inspected. InUser-Agentonly, a complete PowerShell product token (PowerShell/<version>orWindowsPowerShell/<version>, delimited by whitespace) does not match thepowershellkeyword, so stock PowerShell HTTP clients are served; other occurrences and every other signature still match. The| shsignature matches only whenshends at a word boundary and; catonly when a path-like argument follows, so “| shopping” and “dogs; cat food” pass. Percent decoding and body/JSON inspection allocate; this control does not replace typed parsing, SQL binds, validation, authorization, or SSRF allowlists. - Login Guard Tarpit:
record_login_failurereturns progressive delay decisions andrecord_login_failure_and_waitapplies them asynchronously; both share bounded, temporary in-memory jails keyed by a hashed identity. Atmax_identities, a new identity evicts the least recently failed counter and a new offender evicts the soonest-expiring jail; expired state is pruned in time order on every call, and jail telemetry is emitted only for a jail that was actually created.
7.3. MFA and Security Evidence Boundaries
- TOTP enrollment: Secrets contain 160 bits derived from the OS RNG,
verification accepts exactly six ASCII digits with constant-time comparison,
and enrollment can emit an
otpauth://URI or bounded SVG QR. Secret custody, recovery workflow and durable rate limiting belong to the application.verify_totp_codeis stateless and accepts steps -1/0/+1, so it does not reject a replayed code.verify_totp_step_afterimplements RFC 6238 section 5.2 by accepting only a step newer than the caller-supplied last accepted step; the application must persist the returned step per secret atomically before admitting the login. - Security CLI: CycloneDX generation, MSRV/tool diagnostics, unsafe/IDOR source heuristics, network observations and compliance evidence are bounded checks. They do not certify a deployment, prove absence of vulnerabilities or replace provider/CI evidence tied to an immutable SHA.
7.4. Bounded JSON Schema Enforcement
JsonSchemaPolicy::from_schemacompiles an application-supplied JSON Schema 2020-12 document once;from_openapi_componentselects one explicitcomponents.schemasentry from OpenAPI 3.1. OpenAPI 3.0 is rejected because it is not the same schema dialect.- Construction caps serialized bytes, node count and depth, rejects non-local
$ref/$dynamicRef, disables network/filesystem retrieval and selects the linear-time regex engine. The route-scoped Axum middleware first enforces the existing media-type, syntax, duplicate-key, payload-size and depth boundary, then returns422for schema mismatch without echoing values. A JSON media type is recognized in every spelling axum’sJsonextractor accepts (any ASCII case, parameters, ajsonsubtype or+jsonsuffix). - The policy validates JSON bodies only. Authentication, authorization, ownership, business invariants and query/header/form parameters remain separate application boundaries.
7.5. Deterministic Threat Sentinel and Proof of Work
ThreatClassifierassesses a bounded aggregate window supplied by the host against transparent thresholds for credential stuffing, API scraping and distributed automation. It does not collect traffic, infer identity, use a model or attribute a botnet.ProofOfWorkGateissues OS-random, HMAC-authenticated challenges bound to one canonical application subject. Tokens have bounded difficulty, TTL and cardinality; successful verification atomically consumes local state so only one concurrent verifier succeeds in the process.- Classification is evidence, not authorization. The host chooses whether and where to challenge, provides an accessible fallback, rate-limits issuance and owns trusted proxy/device policy. Replay state is process-local; distributed enforcement, durable telemetry and cross-process one-shot consumption require an application adapter.
7.6. Authenticated Local SIEM Journal
AuthenticatedSiemSpoolis the opt-in authenticated counterpart to the compatible unsignedDurableSiemSpool. It normalizes each local v1 event, writes it synchronously under 16 MiB/4,096-record ceilings and authenticates sequence, key identifier, predecessor tag, payload length and exact payload with a domain-separated HMAC-SHA256 chain.SiemKeyRingaccepts one active write key and at most seven historical verification keys. Key identifiers are bounded, key material is consumed into zeroizing storage, and secret-bearingDebugoutput is redacted. A rotation reopens the spool with the new active key plus every still-needed historical key; silently missing or wrong keys fail closed.- This journal detects forged records, interior deletion/reordering and external length changes. Removal of a complete valid tail is indistinguishable from an earlier valid file unless the operator retains a trusted external checkpoint. Path/key custody, permissions, single-writer enforcement, rotation retirement, compaction, retention, backup, transport, retry, acknowledgement and dead-letter handling remain operator/application work.
7.7. Cryptography Providers (since 13.0.0-alpha.4)
- TLS:
aws-lc-rsis the rustls crypto provider of every TLS connection a Rullst crate makes (reqwest, SQLx, Redis, SMTP, MongoDB, OTLP). No Rullst crate enables rustls’ringfeature or a ring TLS feature of SQLx, MongoDB, lettre or testcontainers. - Primitives:
ringstays a direct dependency for HMAC, digests, randomness and signatures, because it builds for wasm and edge targets. - Process default:
Serverstart-up (Coreorm,storage-s3ortelemetry), the OTLP exporter, Redis TLS connections and the standalonerullst-studiobinary install aws-lc-rs as the rustls process default only when none is installed; an application’s provider installed first is kept. Installation never panics. See cryptography providers.
📡 8. IoT, Firmware Security & Protocol Frames (rullst-iot)
8.1. Ed25519 OTA Firmware Gate
- Firmware Verification: Strict Ed25519 signature validation over a cryptographic manifest
[target, version, rollback_counter, firmware_len, firmware_sha256]. - Anti-Rollback Protection: Verification rejects any counter lower than or equal to the state loaded into the manager. The recommended
RollbackCounterStorepath additionally performs an exact compare-and-set and may report success only after a strictly increasing value is durably committed across reset. A retry of the same verified manifest after an ambiguous store failure completes only when the store reports, and a fresh load confirms, exactly that manifest’s counter; an equal stored counter without such an earlier attempt remains a conflict. Atomicity, integrity, wear-leveling and power-loss behavior are obligations of the caller’s platform adapter and require hardware-specific evidence. - Commit Invariant: In-memory partition selection and store-backed counter commit are blocked until full cryptographic verification succeeds.
verified_target_partitionexposes the inactive bank (oppositecurrent_partition) for platform flash/read-back before commit. The v13new_with_running_partitionconstructor takes the bank the bootloader started, while the older constructors assumePartitionAand require platform code to setcurrent_partition; a commit selects the other bank for the next boot but does not changecurrent_partition, so an update verified again before reboot never targets the running bank. The compatibilitycommit_verified_updatepath is process-local and does not claim persistence, flash or bootloader control.
8.2. Embedded Sensor Frames (#![no_std])
rullst-iotcore models compile under bare-metal#![no_std]targets (STM32, ESP32-C3, Cortex-M).cargo rullst make:iot <DeviceName>generates and registers a local telemetry module, enables the umbrellaiotfeature and refuses unsafe names or collisions. It does not install firmware, a HAL, MQTT or CoAP.IotDashboardrenders an escaped HTML snapshot. It does not infer online state or provide a live device connection.MqttPublishencodes one bounded MQTT 5 PUBLISH packet with validated topic, minimal Remaining Length, QoS/packet-identifier invariants and an empty property section.CoapRequestencodes bounded RFC 7252 base requests with a token, ordered URI-Path (1-255 bytes per segment, never.or..)/Content-Format options and a non-empty payload marker. Both compile underno_std; neither opens a socket or owns protocol session state.- 🔵
[Roadmap]MQTT/CoAP Transport: Async connections, TLS/DTLS, broker negotiation, acknowledgements, retransmission/congestion control, block-wise transfer and interoperability matrices remain separate integration work.
🤖 9. AI Agent & LLM Orchestration (rullst-ai)
9.1. Guarded AI Client
-
AiClient::auto()and Nexus shareAutoAiConfig. Resolution uses one snapshot in OpenAI/custom, Anthropic, Gemini, DeepSeek, Groq, Ollama order. Empty environment values are absent; explicit mocks remain offline. Groq requiresGROQ_API_KEYplusGROQ_MODEL; a customOPENAI_BASE_URLrequiresOPENAI_API_KEYplusOPENAI_MODELand uses the HTTPS chat-only compatible adapter. Configuration indicators do not attest network/account health. -
Provider-agnostic interface for Google Gemini, OpenAI, Anthropic Claude, DeepSeek, Ollama, and explicit OpenAI-compatible endpoints. The compatible adapter is chat-only by default; applications declare optional request shapes for one exact model. Loopback may be unauthenticated, cloud requires HTTPS/Bearer, and unrelated protocols implement the public
AiProviderboundary rather than passing through arbitrary HTTP. -
Prompt Injection Firewall: Real-time token heuristics intercepting prompt exfiltration, instruction overrides (
DAN mode), and delimiter injection attacks. -
Automated PII Masking: Scrubs check-digit-valid CPF/CNPJ numbers (canonical formatted or unformatted), card-like digit runs and email usernames prior to outbound LLM dispatch. Alphanumeric CNPJs and other identifiers are not recognized.
-
Token usage (v13):
chat_with_usage/send_with_usagereturn aChatCompletionwith optionalTokenUsageparsed only from documented provider fields (OpenAI/DeepSeekusage, Anthropicusage, GeminiusageMetadata, Ollama eval counts, compatible servers’usage). Streams report it throughAiStreamSink::usageandStreamSummary::usage(); compatible configurations request it only withwith_stream_usage(). Missing counts areNone, never estimated.
9.2. Bounded Streaming and Cancellation
StreamingAiClient<P>preserves static dispatch, reapplies the mandatory input guardrails and enforces at most 4,096 non-empty chunks, 64 KiB per chunk and 2 MiB aggregate output independently of the provider.- An OpenAI-compatible configuration may explicitly declare SSE streaming. The
transport requires
text/event-stream, bounded raw bytes, supported chat deltas and[DONE]; malformed, truncated or oversized streams fail closed. - Anthropic (Messages SSE, requiring
message_stop) and Gemini (streamGenerateContent?alt=sse, requiring a finalfinishReason) stream natively (v13) under the same bounds and guardrails; unknown Anthropic events are ignored and error events expose only their type. Truncated or withheld replies (Anthropicmax_tokens/model_context_window_exceeded/refusal, GeminiMAX_TOKENSor safety-class reasons) fail in streaming and non-streaming paths alike. AiCancellationraces the initial request and every streamed body read. It drops local transport work but does not prove upstream cancellation or stop provider billing. Non-compatible protocols and ordinary non-streaming calls retain deadline/drop semantics.
9.3. Bounded Tenant-Aware RAG
RagPipeline::answerrequires a trusted CoreTenantContextand composes guarded embedding, a static-dispatch applicationRagRetriever, bounded context selection, guarded generation, source metadata, and one required terminalRagAuditSinkevent.- Retrieved documents carry the trusted tenant tag. The pipeline rejects
mismatches, over-return, injection heuristics, empty context, non-finite
embeddings, and unavailable mandatory audit evidence rather than silently
generating an ungrounded response. A guardrail block of the assembled prompt
is audited as
ContextRejectedbefore any provider call. - Context limits count Unicode scalar values per document and in total. The audit event omits raw question, context, embeddings, provider bodies, and answer; its SHA-256 query digest is correlation metadata, not encryption.
InMemoryRagRetrieverandInMemoryRagAuditTrailare bounded process-local development/test implementations.DurableRagAuditTrailandDurableToolAuditTrailadd bounded, synchronously persisted, versioned local evidence with restart validation and fail-closed corruption/quota handling. They are single-process writers; their SHA-256 frames detect corruption but do not authenticate events. Production hosts own authoritative tenant and ownership predicates, durable/external vector adapters, ingestion/deletion, model/vector compatibility, output policy, directory permissions, rotation/retention/backup, external audit delivery, tuning, evaluation and recovery.
9.4. Bounded Conversational Memory
StatefulChat<M>uses static dispatch overChatMemory, requires a trustedTenantContextand validatedConversationId, loads only the configured even number of recent messages, applies the guardedAiClient, and persists the user/assistant exchange only after generation succeeds. A response that the guardrail would block on replay, or that is empty or larger than 64 KiB, is rejected as a generation failure and not persisted.InMemoryChatMemoryis deterministic, tenant-partitioned, cardinality-bound, and intended for tests/local use. The opt-insql-memoryadapter supports SQLite, PostgreSQL, MySQL, and MariaDB through a dedicated SQLx Any pool.- The SQL adapter advances an even conversation revision and inserts both
messages in the same transaction. A compare-and-swap predicate rejects stale
cross-process writers; Rullst deliberately does not retry the provider call.
History reads bind the tenant/conversation and never include rows newer than
the revision observed by that read. Tenant and conversation IDs compare
case-sensitively on every backend: MySQL/MariaDB tables declare the key
columns
CHARACTER SET ascii COLLATE ascii_bin, and each tenant-scoped MySQL/MariaDB statement also compares the key byte-exactly.prepare_schemanever alters an existing table; a legacy case-insensitive table fails closed for IDs that differ only by case (InvalidConfigurationonensure_conversation) until the operator applies the documented migration. - Message text is not encrypted by this adapter. Authenticated conversation ownership within a tenant, retention/erasure, provider audit, backups, migration governance, and user-facing conflict retry remain host policy. The generated Turso/custom-model scaffold is a separate application-owned path.
9.5. Authenticated Audit Export
AuditDeliveryClientexports an application-minimized serializable event in a versioned JSON envelope of at most 16 KiB. Cloud endpoints require HTTPS; local HTTP(S) requires a literal loopback IP. Redirects and ambient proxies are disabled.- HMAC-SHA256 covers a domain separator, key ID, Unix-millisecond timestamp and
the exact body. A caller-supplied event ID remains unchanged across one to
five attempts. Only transport/deadline errors, HTTP 429 and HTTP 5xx are
retryable, and a closed acknowledgement of at most 8 KiB must bind that ID.
AiCancellationraces request, body and retry waits. Empty ormock_*keys select deterministic offline behavior. - A timeout may follow remote acceptance. The receiver therefore owns signature and freshness verification, event-ID deduplication, authorization, persistence, retention, key distribution/rotation and operational availability. The client does not minimize arbitrary event values or provide a durable outbox/SIEM service.
9.6. Bounded Adaptive Evaluation
AdaptiveAiEvaluator<P>keeps static provider dispatch and reapplies the mandatory prompt guardrail on every strategy-generated prompt. A scenario is capped at 32 turns, 16 KiB per prompt and 2 MiB per response, with an independent per-turn deadline andAiCancellationraced against each call.AiEvaluationStrategytemporarily receives the bounded response or a low-cardinality guardrail/provider/deadline outcome and explicitly chooses pass, fail, inconclusive or a next prompt. The synchronous strategy is application code and must not persist/log model text without policy.- The version-1 JSON report records caller-supplied suite and exact model/configuration subject labels, provider name, terminal code and only per-turn byte counts/outcomes. It retains no prompt, response or provider error. Subject labels are assertions, not automatic model discovery.
- Deterministic repository fixtures prove bounds, feedback, redaction, cancellation and status semantics. Operators still version domain corpora, run them against every exact live model/configuration and review results; a pass is not universal safety, groundedness or jailbreak-resistance evidence.
📊 10. Control Center & Admin Interfaces (rullst-studio & rullst-nexus)
10.1. Rullst Studio (http://127.0.0.1:5555)
- Local-first developer dashboard with a server-rendered dark interface; browser assets and final page policy remain deployment concerns.
- Process observations sourced from
RadarSnapshot::collect()and explicitly supplied local collectors; unsupported values remain unavailable. - Generated applications start the standalone Studio only in debug builds and
bind it to loopback. Its local capability verifies the direct loopback peer,
accepts only a local
Hostauthority, requires same-originOriginon unsafe methods, and rejects missing origins on mutations. Responses carryReferrer-Policy: same-originso that browser form posts keep the real origin;Origin: nullis accepted only withSec-Fetch-Site: same-origin, never alone or from a same-site document. This is a local DNS-rebinding/CSRF boundary, not production authentication. - Queue, revenue, security and telemetry pages report only values supplied by
their configured process-local source. The queue page labels its status
counts as covering only its 50 most recent records; the pending count and
purges cover the whole queue. Its previews are cut to 256 payload and 512
error characters. The listing uses
QueueDriver::list_job_previewswith a 2 KiB budget per payload and error, which SQLite and Redis apply in the store; a custom driver without its own implementation still loads complete records through thelist_all_jobsprojection. Unsupported driver operations and disconnected integrations remain errors orUnavailable. The standalone migration surface provides CLI guidance and returns501from legacy mutation handlers because no migration/seeder registry is installed. - Studio’s database views use the application’s ORM pool or, when none
exists, the shared
Server/Artisan database URL resolver (section 4.1). There is no implicit SQLite fallback; resolution errors never echo configuration content. - The database browser accepts a deliberately narrow ASCII SQL-identifier
boundary. Reads are bounded: a page loads at most 25 rows, ordered by the
complete primary key (otherwise by every selected column) so that pages
neither repeat nor skip rows, the database cuts
each cell’s text to 256 characters (key columns keep up to 16 KiB so that
rows remain addressable; a longer key makes the row read-only) and search
terms are limited to 256 bytes. Search matches the displayed columns (at most
256), and the record count uses the same predicate. Writes require the crate-private proof inserted
by the verified local middleware, database-inspected table/column/complete-PK
metadata, a 64 KiB request limit, primitive typed binds and exactly one
affected row. A row offers only the actions whose form (for Edit, with a
maximum-size value) fits that limit, and an empty text key is addressable. A primary-key column outside the identifier boundary or the
256-column cap makes the table read-only instead of shortening the key, as
does a floating-point key column, whose rendered text is rounded. Rows whose
key is
NULL, is not decodable as text or contains a line break or NUL (which browsers rewrite in form values) offer no actions. Each write runs in a transaction that commits only for exactly one affected row; zero or several rows are rolled back and reported as404/409(engines without transactions, such as MySQL MyISAM, cannot roll back). Primary keys/backend-specific values (including MySQL/MariaDB unsigned or zero-filled integers) are read-only, while delete requiresDELETE <table>. SQLite, PostgreSQL, MySQL and MariaDB run separate mutation contracts; PostgreSQL runs under both the defaultsqlx::Anybuild, where Studio renumbers bind markers and casts information-schema identifiers toVARCHAR, andstrict-postgres. On PostgreSQL Studio browses thepublicschema and names it in every data statement, sosearch_pathcannot resolve a table to a same-named table in another schema. This is not application authorization, tenant scoping, audit, rollback or shared-production administration. The ER diagram inspects the same relational backends with bound lookup values and strict normalized Mermaid identifiers, lists each column once and pairs composite foreign-key columns by key position. The read-only API reference requires an application-suppliedOpenApi. - Request SSE records method, URI, status, and latency without bodies or headers.
Environment values are redacted by default; an allowlisted value is also
redacted when it carries URL user information, a bearer token or a
secret-named assignment. The typed config projection
never renders connection URLs, filesystem paths, cookies, tokens, or
credentials. A successful Studio database-flag mutation invalidates warm
DbFeatureDrivercaches in the same process; direct writers and other processes remain subject to TTL unless the host distributes invalidation. The driver also caches missing flags and failed or two-second-timed-out lookups for its TTL, serves the last value read after a failed refresh and bounds its cache to 4,096 flag names. SQLite removes completed jobs by default. An explicit 1–100,000-row history policy retains and atomically prunes real completion records for Studio, with a separate purge; retained payload access and lifecycle belong to the host. Redis lists at most 1,000 failed, dead-letter, processing, pending and scheduled jobs, retries failed jobs and purges failures; custom queue inspection remains capability-specific. Queue retry/purge and feature-flag toggles require the same crate-private verified-local marker as database writes, so their raw routers return403outside the local boundary. SafeGETviews perform no schema or data writes; the feature-flag page reports a missingrullst_feature_flagstable instead of creating it; other query failures (connection, permission, column types) are reported without migration guidance. Studio::with_cacheis an explicit metadata-only diagnostic capability. The memory and Redis cache drivers return at most 200 sorted entries containing logical key, UTF-8 value byte length and remaining TTL; custom drivers returnInspectionUnsupportedby default. Studio displays at most 100 keyed opaque identifiers and never cache values or exact logical keys. Individual invalidation requires its unforgeable verified-local marker and a fresh process-bound HMAC token. The page has no bulk flush operation.- Distributed trace producers use separately mounted, push-only routers; each endpoint binds one exact producer name to one key and multiple producers use separate endpoints/keys over the same shared store. A v1 batch is at most 128 KiB and 128 spans, carries W3C-compatible IDs, a bounded source/operation/kind/timing/status set, and accepts no attributes, SQL, bindings, headers, bodies or error strings. HMAC-SHA256 authenticates the exact body and source/timestamp/nonce headers; a 60-second window and bounded atomic nonce cache reject replay, while the shared in-process store is capacity-bound and idempotent by trace/span ID. The local viewer reports a fixed 100 ms slow-query signal and a three-equal-label possible-N+1 heuristic. This is not an OTLP collector, durable backend, secret manager, remote viewer/login, or proof that a repeated operation is defective. TLS, network policy, key distribution/rotation, clock synchronization, producer label redaction, retention and availability belong to the deployment.
- Exposing Studio beyond the developer machine requires an explicit authenticated network boundary owned by the application; no environment variable silently converts the local server into a production admin surface.
10.1.1. Production monitoring endpoint (v13, 13.0.0-alpha.3)
Phase 1 of the Studio production monitor: a read-only machine contract served
by the application, which a later Studio “remote projects” mode reads. Guide:
production monitoring; threat model TM-STUDIO-2.
- Mounting.
Server::with_monitor(rullst_core::monitor::Monitor)mountsGET /_rullst/monitor/v1(Core’s reserved/_rullst/*namespace, schema major version in the path) and a recording layer outside the framework layers, so their refusals are counted. At start-up the server adds the comma-separated tokens ofRULLST_MONITOR_TOKENS(process environment, then.env) toMonitor::with_tokenvalues. With no token and noMonitorAuthorizer, nothing is mounted (404) and request handling is unchanged. Invalid tokens (fewer than 43 base64/base64url or 64 hexadecimal characters, other characters, more than 512 characters, fewer than ten distinct characters, placeholder words, more than eight tokens), invalid application labels (1–128 bytes, no control characters) and invalid queue names (1–64 of[A-Za-z0-9._-], unique, at most 16) stop start-up withServerError::Configuration, whose message names a position, never a token. The hot-reload development server does not mount it. Since 13.0.0-alpha.3 thesrc/main.rsof the Blank (web and JSON API) and SaaS starters (and of the LMS example) carries thewith_monitorcall commented out, and their.env/.env.examplelist# RULLST_MONITOR_TOKENS=commented out with a generation hint; nothing is enabled by default. - Admission. In order: a request with
Origin, orSec-Fetch-Sitecross-site/same-site, gets403; in staging and production a request without TLS evidence gets426(evidence:httpsreported by a trusted proxy withtrust_forwarded_proto, the application-insertedMonitorVerifiedTlsmarker, or, withMonitor::allow_loopback_plaintext, a direct loopback peer with a loopbackHostand no forwarding header); a per-client budget of 30 requests refilled at one per second, then a lockout after 10 presented invalidBearervalues refilled at one per 90 seconds, both429withRetry-Afterand keyed per IPv4 address or IPv6 /64 after trusted-proxy resolution. A locked client’s credentials are not evaluated. ThenAuthorization: Beareris compared with every token’s SHA-256 digest in constant time; otherwise theMonitorAuthorizeranswersAllow(200),SignIn(401),Deny(403) or an error (503, reason logged). Missing and invalid credentials get one empty401withWWW-Authenticate: Bearer realm="rullst-monitor". Every response of the route carriesCache-Control: no-storeandX-Content-Type-Options: nosniff; other methods get405. - Document (
rullst.monitor.v1).schema,generated_at,application(name,versionornull),framework,uptime_seconds,process(radar),http(window_seconds,route_limit,routes_overflowed,requests_total,status_total,window,routes),database(observedwithqueries_totalandn_plus_one, orunavailablewith a fixed reason) andqueues. A test pins this exact field set. Route keys are the matched route pattern (Axum’s nested tail shown as{*rest}, control characters replaced, at most 256 bytes) or(unmatched), and a method from the standard set orOTHER; at most 256 pairs, then one*/(other)bucket. Windows are five one-minute slots with a 17-bucket latency histogram (0.1 ms to 10 s and an open bucket); a percentile is its bucket’s upper bound capped at the window maximum. N+1 findings use therullst_core::query_patternsrule on ORM label fingerprints attributed to a request scope, at most 64 entries. Queuependingcomes from the store within 250 ms per queue (timeout/driver_errorotherwise); in-flight, completed, failed, retried and stalled-recovered counts cover this process’s workers. Raw paths, query strings, headers, cookies, addresses, user identifiers, bodies, SQL text or values, error messages, environment and configuration values are never recorded. - Cost and scope. State is process-local and resets with the process. The passive ORM query layer is part of the framework subscriber in every build and attributes operations only inside a recording scope. A release-build measurement adds about 0.6 µs per request (guide, “Overhead”).
10.1.2. Studio remote projects (v13, 13.0.0-alpha.3)
Phase 2 of the production monitor; threat model TM-STUDIO-3; guide:
production monitoring.
- Process.
rullst_studio::remote::run_remote_studio(therullst-studiobinary, also started bycargo rullst studio --remote) exists only with the non-defaultremotefeature ofrullst-studio(which addsreqwest,ringand SQLx’s SQLite driver), so applications that mount Studio in development build none of it. It listens on127.0.0.1only (default port 5556) and needs no application process. Its router applies Studio’s loopback boundary (loopback peer andHost, same-originOriginon unsafe methods, crate-private marker) in release builds too, and mounts only/studio(projects),/studio/radar,/studio/routes,/studio/queues,/studio/queriesand the assets; local surfaces (data browser,.env, AI, API, migrations, jobs, cache, features, requests) answer404. Add and remove forms also require a random per-process form token compared in constant time. - Storage.
projects.jsonin$XDG_CONFIG_HOME/~/.config/%APPDATA%underrullst/studio, andprojects.key(32 random bytes) under the data directory ($XDG_DATA_HOME/~/.local/share/%LOCALAPPDATA%). Files are written atomically, owner-only (0600in0700directories on Unix), read with a size bound, never through a symlink; unknown fields are rejected. At most 32 projects; names are 1–48 of[A-Za-z0-9._-]starting with a letter or digit. Tokens are sealed with AES-256-GCM (random 96-bit nonce) with the project name and normalized base URL as associated data; a token that does not decrypt is reported and never sent. - Requests. Base URLs are
https://, orhttp://to a loopback host, without user information, query or fragment. Studio sendsGET <base>_rullst/monitor/v1withAuthorization: Bearer, follows no redirect, ignores proxy variables, validates certificates, and bounds each read to 1 MiB, 5 s total and 3 s to connect. It accepts only200, a JSON content type andschema: "rullst.monitor.v1"with bounded lists (512 routes, 128 findings, 32 queues) and labels (512 characters); unknown fields are ignored. Failures are fixed statuses; every remote value is HTML-escaped. - History and alerts (phase 3). Projects marked watched are read every
60 s (clamped to 15 s–1 h) while the process runs. Each read stores one row
of aggregates (reachable, requests per second, window requests, 5xx rate,
p95, total pending and failed queue jobs) in
history.sqlitein the data directory (created0600, directory0700); every write deletes rows older than 7 days and beyond 10,080 per project, and keeps the newest 500 alerts. Rules per project (unreachable; 5xx rate, needing 20 window requests; p95; pending jobs; failures between two polls, ignoring counter resets) fire at most once per cool-down (15 minutes, 1–1,440) for each rule, with messages built from fixed wording, the project name and numbers./studio/historycharts the samples as inline SVG;/studio/alertslists alerts, sets each project’s watch flag, rules and webhook (same form token) and acknowledges alerts. A webhook URL must behttps://without user information or fragment, is sealed like a token and is never displayed; each alert is posted once as JSON (source,project,rule,message,at) without redirects or proxies within 5 s, and the fixed outcome is stored on the alert.
10.2. Rullst Nexus (/nexus)
- Auto-generated CMS with dynamic CRUD operations and AI Admin Assistant.
- The AI assistant (
/nexus/chat,/nexus/chat/query) is theaifeature ofrullst-nexus(default for the crate) and thenexus-aifeature of therullstfacade, whosenexusfeature no longer enables it (13.0.0-alpha.4);cargo rullst newenablesnexus-aiwhen the SaaS starter is created with AI. Without the feature Nexus does not depend onrullst-ai, shows no assistant link, and the same two routes answer404with a notice that names the feature; the public handlers keep their signatures and render that notice. With it, routes and behaviour are unchanged. - Security Default: Fail-closed by design; requires explicit authentication middleware and RBAC role validation (
admin) on all mutating endpoints. - Generated applications may use
NexusAuthPolicy::local_development_or_basic_from_env(): debug builds accept only a peer address verified as loopback throughConnectInfo(IPv4-mapped peers are canonicalized), a localHostand, for unsafe methods, a same-originOriginorOrigin: nullwithSec-Fetch-Site: same-origin(local responses default toReferrer-Policy: same-origin), while release builds require validated Basic Auth credentials from the process environment, then./.env(config::project_setting; an unreadable or malformed.envisNexusBuildError::InvalidDotenv). Missing peer metadata is denied, and an environment mode flag cannot enable unauthenticated release access. NexusAuthPolicy::application(authorizer)(v13) lets the application’s own sign-in decide: Nexus calls the application’sNexusAuthorizerwith the request’s method, URI, headers and extensions on every request, before any data is read or rendered, so a revoked session or permission takes effect on the next request.NexusAccess::Allow { actor_id }admits the request under that audit actor (1 to 255 bytes, unpadded, without control characters; any other value answers503);SignInredirects a page request (GET/HEADwithoutHX-Request) to the login page with303and answers fragments and other methods401withHX-Redirect, which htmx andnexus.jsfollow;Denyanswers403; an authorizer error answers503. The login page is/loginorNexusApplicationAuth::with_login_path, which must be a local path of at most 512 visible ASCII characters starting with a single/and without\(NexusBuildError::InvalidLoginPathotherwise). The policy needs no TLS evidence; CSRF, the administrator role layer, tenant scope and the audit apply unchanged.local_development_or_applicationselects the debug loopback policy instead in debug builds.- The SaaS starter and the LMS example use
local_development_or_applicationwith a generatedNexusAdminsauthorizer (src/middlewares/nexus_access.rs) that admits the user of the epoch-checked session cookie whoseusers.is_admincolumn is true (default false, skipped by ORM writes, editable in Nexus;cargo rullst authusers have it too). The Blog, Portfolio and ERP examples (the former starters) keeplocal_development_or_basic_from_env(); whenNEXUS_ADMIN_*are missing in a release build the Portfolio and ERP start without Nexus (the ERP also without its back office) and print one line naming the variables, and the Blog does not start; a weak, placeholder or unreadable credential stops startup in all three. - Basic Auth counts only presented Basic credentials that fail, per client bucket (IPv4 address or IPv6 /64 of the
ConnectInfopeer); a credential-less401challenge is not a failure. A locked bucket receives429without credential evaluation, except for a browser holding the per-processrullst_nexus_known_clientcookie issued on a previous success. Behind a shared reverse proxy, Core’sTrustedProxyLayer(Server::trusted_proxies) configured with only the proxy’s networks supplies the resolved client asConnectInfo, isolating clients. - Basic Auth requires TLS evidence: the application-inserted
NexusVerifiedTlsmarker, or aClientAddrwhoseforwarded_proto()is HTTPS, which only the trusted-proxy layer can produce and only withtrust_forwarded_proto(true). A request URI or forwarding header alone is never TLS evidence. - A model may explicitly declare one text
tenantcolumn. Nexus then obtains the scope only from a trusted CoreTenantContext, injects it on create, includes it in every built-in read/mutation/batch predicate, and denies a missing context. Models without that metadata remain global administrator models. Authentication and tenant-membership resolution remain host contracts. - Updates write only submitted fields, and the edit form (
data-nexus-mode="edit") submits only controls the administrator changed. NULL, unregistered enum values, offset or sub-millisecond date-times and undecodable values render without a widget default that could overwrite them. An emptied number/relation/date/date-time/enum/JSON value is NULL (omitted on create), never''; the semantic validator rejects''for those kinds. AFieldKind::Integer { min, max }value must be a whole number in range and is stored canonically;Numberaccepts any finite number. Date and time kinds are parsed per kind and bound as typed values:Date(YYYY-MM-DD, year 0001 to 9999) as a date,Time(HH:MM[:SS[.fraction]], v13) as a time, a localDateTime(Tor space separator) as a date-time without a zone, andDateTimeUtc(v13) as a UTC instant, where a value without an offset is a wall time in the panel’s time zone and one withZ,±HH:MMor±HHis converted. The zone is UTC unlessNexus::with_time_zoneselects aNexusTimeZone(v13, 13.0.0-alpha.3): a fixed offset, or with thetzfeature (nexus-tzin the facade) an IANA zone whose rules come fromjiff(system zoneinfo, bundled fallback). A wall time that a daylight-saving gap skips is a422naming the zone; one that a fold repeats is read as its earlier instant. A text column stores them asYYYY-MM-DD,HH:MM:SS[.fraction],YYYY-MM-DD HH:MM:SS[.fraction](the text form ofCURRENT_TIMESTAMP, so text comparison and ordering agree with application-written rows) and RFC 3339 respectively; aDateTimevalue with an offset is bound as text, as entered, which only a text column accepts. A malformed value is a422validation error.Timefields use atimeinput andDateTimeUtcfields adatetime-localinput labelled with the zone’s name (UTC,UTC±HH:MMor the IANA name) that shows the stored instant as a wall time in that zone; the later occurrence of a repeated wall time is shown as its UTC value (Z) in a text input so saving the form unchanged keeps it, and a value either picker cannot hold exactly (finer than milliseconds) is shown in a text input. - Record keys follow the registered primary-key kind: a
number,integeror relation key must be a canonical integer (+1,01and1e3name no record, so the audit key is the changed key); other kinds are compared as text. A model may declare a composite key (NexusModel::nexus_pk_columns, v13; at most 8 distinct registered columns, none of them the tenant column): a record is then addressed by its values in key order, each percent-encoded (urlencoding::encode) and joined by,; only that canonical spelling with the right number of values names a record, each value typed by its column, and every key column is bound in list, edit, update, delete, batch and create predicates, read-only on edit and a sort tie-breaker. The UI addresses records under/table/{table}/record/{key}so keys such asnew,searchorbatchnever meet an action route. Other form values are bound as text; on PostgreSQL, numbers and integers are written throughNUMERIC, canonical-integer relations throughBIGINT, Booleans as untyped'0'/'1'literals and dates and times (including NULL) throughDATE,TIME,TIMESTAMPorTIMESTAMPTZcasts, so native columns accept them, while other kinds need text columns. MySQL/MariaDB receive a UTC instant as its UTC wall time. Search matches the typed text literally and case-insensitively (ILIKEon PostgreSQL) in visible text, textarea, e-mail and URL fields; a model without one shows no search box and a search query lists no rows. The tenant predicate compares binary strings on MySQL/MariaDB. Passwordfields are never rendered: list cells show a fixed mask (the column is not selected or sortable; neither is a hidden field, so asort_bynaming either orders by the key) and edit forms an empty input; an empty submission keeps the stored value. Non-empty values are written verbatim; Nexus neither hashes them nor runs ORM hooks, so credential-hash columns must be read-only in Nexus and changed by an application flow that hashes.#[derive(Nexus)]follows ORM field semantics:#[orm(skip)]/#[sqlx(skip)]fields are not columns;#[orm(encrypted)],SecretStringand#[orm(hidden)]fields become hidden, read-onlyPasswordfields that are never listed, searched, sorted, rendered or written (only#[nexus(kind = "password")]turns an#[orm(hidden)]field into a write-only field, and any other widget override on these fields fails compilation);#[orm(masked)]fields default toPasswordunless an explicit#[nexus(kind)]shows them. The record key is the struct-levelprimary_key(one column, or up to 8 distinct columns separated by commas), else every#[nexus(primary_key)]field in declaration order, elseid; annotations must match a struct-level list. A single integer key is hidden and read-only as database-generated, while another key, and every column of a composite key, is entered on create and read-only on edit.chrono::NaiveDate,NaiveTime,NaiveDateTimeandDateTime<Utc>fields becomeDate,Time,DateTimeandDateTimeUtc. Rust integer fields (also underOption, and under an explicitkind = "number") becomeFieldKind::Integerbounded by their type (isize/usizeas 64-bit,u64/usizecapped ati64::MAX);f32/f64stayNumber.- The panel loads only same-origin assets under
/nexus/assets/(stylesheet, behaviour script and a vendored, unmodified htmx 2.0.4 with eval, script tags, injected indicator styles and itslocalStoragehistory cache disabled). Nexus markup contains no inline script/style blocks, event-handler,hx-onorstyleattributes, so the default production nonce CSP applies unchanged; the application-widesecurity.cspmust not be relaxed for Nexus. AI assistant replies are sanitized to an explicit text-formatting allowlist without any element that loads a resource by itself (noimg), and links getrel="noopener noreferrer". with_required_auditrequires the fixedrullst_nexus_auditsschema and appends one minimized committed-mutation row in the same transaction. Audit unavailability rolls the data change back. This is not append-only, tamper-evident, denied-attempt, retention, backup, replication or external-SIEM evidence; those properties remain host responsibilities.
🛡️ 11. Architectural Guidelines for Backward Compatibility
#[non_exhaustive]on Public Structs: All configuration structs and enums must use#[non_exhaustive]to ensure minor versions can add fields without breaking downstream code.- Deprecation Policy (
#[deprecated]): Public APIs will never be removed without at least one minor release cycle marked with#[deprecated]. - Ergonomic String Constructors: Public constructors accept
impl Into<String>to support both&strliterals and ownedStringparameters without boilerplate. - Zero-Panic Invariant: Production paths must never call
panic!(),unwrap(), orexpect(); domain errors must return typedResult<T, AppError>.
11.1. Generated Project Context (v13)
generate:ai-context retains its existing helper signature but replaces raw
source/configuration embedding with the rullst.project-context.v1 inventory.
It writes a readable .llms.txt and machine-readable .rullst/context-map.json,
and creates a project AGENTS.md only when no user instructions already exist.
New project generation installs the same instructions and inventory. Existing
AGENTS.md files are never rewritten by regeneration or automatic scaffold hooks.
The map contains bounded project/dependency/feature names, validated dependency
version requirements, configuration key
names, source paths and roles, file sizes and one deterministic input fingerprint.
It contains no source bodies, configuration values, dependency URLs or absolute
workstation paths. Only bounded Cargo.toml, optional Rullst.toml and
.env.example, and regular .rs files beneath src are inspected. Secrets,
databases, VCS metadata, build output, dependency directories, hidden descendants
and symlinks are excluded or rejected explicitly. Directory depth, entries,
individual files, aggregate reads and output size all have hard limits; errors
must not echo configuration values. Generated instructions identify the map as
an inventory, not evidence that a feature, deployment or test has passed.
The dotenv inventory accepts single-line declarations and rejects multiline
values before they can be mistaken for key names; it performs no interpolation.
The selected root’s src is explicit: external workspace members are not scanned.
--check recomputes and compares the generated inventory without writing files;
stale, missing or altered output fails. Normal regeneration uses preflighted
atomic replacement/rollback, refuses links and unrecognized output, and retains
the legacy generated .llms.txt migration boundary explicitly. The fingerprint
detects changed Rust source and inventoried metadata, excluding configuration
values and dependency URLs; it is not a signature or an authorization claim. The
workspace directory is trusted against concurrent adversarial filesystem edits.
Local unit and real CLI/new-project contracts passed for the bounded inventory. Hosted and installed-archive source acceptance passed in PR #221, as recorded in the delivery plan; final release admission remains separate.
11.2. Schema-First API Profile (v13)
generate:api --schema <file> --output <directory> is an opt-in generator with
one explicit OpenAPI 3.1 JSON source. It must reject unsupported keywords and
shapes, external/cyclic references, duplicate keys, ambiguous names/routes and
unbounded input before writing anything. The existing route-scanning generators
remain discovery aids; their placeholder schemas do not enter this typed profile.
The initial profile admits closed named objects, bounded strings/arrays, booleans, JavaScript-safe integers, required nullable scalar/array fields and optional non-nullable fields. Optional nullable fields, arbitrary maps, polymorphism, floating-point/64-bit numeric wire values, formats and recursive schemas remain unsupported. References are local named object components. Parameters are explicit path strings and scalar query values; request and response bodies are named JSON objects with explicit status codes. Bearer authentication is described explicitly, while identity, ownership, CSRF and authorization remain host application responsibilities.
Generated Rust DTOs use Serde, and explicit operation codecs reuse Security’s bounded duplicate-key inspection and offline JSON Schema policies. They validate input before deserialization and output before serialization. Generated TypeScript uses strict types, runtime validation and typed status/body unions; its HTTP client bounds response bytes/time, encodes parameters, refuses redirects and never includes tokens or rejected payloads in errors. No route is silently mounted and no schema declaration constitutes authorization.
Generation preserves unrelated files, preflights recognized outputs and exposes a read-only freshness check. Hard budgets apply to source/output bytes, schemas, operations, properties, parameters and reference depth. The trusted project directory is not an adversarial concurrent filesystem boundary. Acceptance requires compiled generated Rust and strict TypeScript, plus a real HTTP consumer journey covering Unicode, missing versus null, bounds, typed errors and denied cross-owner access. Local generation, compiled Rust/strict TypeScript and HTTP consumer contracts passed, including read-only APIs, exact safe-integer bounds, nullable nested arrays, duplicate queries/JSON keys and cancellation. The initial implementation passed hosted and installed-archive source acceptance in PR #221; final release admission remains separate. See the supported profile for exact limits and application wiring.
11.3. Terminal AI Assistant (removed in 13.0.0-alpha.4)
cargo rullst ai, the terminal assistant of the earlier 13.0 prereleases
(with connect, status, disconnect, upgrade, fix and review), is not
part of 13.0. Its code remains in git history only and no package ships it.
cargo rullst ai … is a hidden command that prints that it was removed, what
replaces it and the migration guide link, and exits with status 1. Developers
use their editor’s or terminal’s assistant with the project’s AGENTS.md and
.llms.txt (cargo rullst generate:ai-context) and the published framework
index (llms.txt). The rullst-ai crate is unaffected. The development error
page’s “Ask your AI assistant” panel replaces cargo rullst ai fix <error-id>:
a read-only prompt quoting only what the page shows (message, location, the
first project stack frames, request method and path) and, when Server
records the error, the path of its loopback-only JSON context
(/_rullst/errors/{id}). The retired POST /_rullst/autofix points there.
🔄 12. Assisted Framework Upgrade Contract
cargo rullst upgrade is the canonical application-upgrade boundary. It is an
assistant, not a claim that compilation proves production compatibility.
-
🟢
[Implemented / Bounded]Planning:--dry-runenumerates only Cargo workspace manifests, preserves TOML comments/order, understands normal, inline-table, workspace, target-specific and renamed Rullst dependencies, and reports path/git dependencies that have no version instead of guessing.--dry-run --jsonemits the versionedrullst.upgrade-plan.v1envelope. -
🟢
[Implemented / Bounded]Versioned Rules: source findings are selected from a versioned rule catalog using detected source majors and the exact target major. Every future major release must extend that catalog, migration documentation, negative tests and process-level fixtures for its supported upgrade paths. The v13 catalogrullst-upgrade-rules-v4parses Rust sources withsyn(comments, doc comments and strings never match an API rule; macro bodies are read as tokens), Cargo manifests and generated project files read without following symlinks. Each finding has a stableV13-*code, a kind (must-changefor a compile-breaking or API-shape change,reviewfor changed behaviour of an API, feature or configuration in use),file:line, a one-line message and the first-column title of its row inmigration-v13.md; the assisted-upgrade tutorial classifies every row. Each rule records the release that introduced its change (13.0.0-alpha.1 for the 12.x to 13 changes, a later prerelease for rules added or reworded since). The plan takes the project’s Rullst version to be the oldest version a Rullst requirement admits orCargo.lockholds and, for a project already on v13, applies only the rules introduced after it, reporting how many rules it skipped and how many findings they would have added;--all-rulesapplies every rule, and project preparations always do.rullst.upgrade-plan.v1is extended additively (kind,introduced_in,migration_row,migration_url,finding_counts,unscanned_sources,migration_guide,rule_selection). Findings never blockcargo rullst upgrade; project preparations with must-change findings are refused at verification, review and application. -
🟢
[Implemented / Bounded]Transaction: the default target is the exact installedcargo-rullstversion;--toaccepts only the same major train as that CLI. Before writes, the command snapshots workspace manifests, the root lockfile and Rust sources undertarget/rullst-upgrades. It applies only dependency edits and compiler-providedcargo fixchanges, then requirescargo check --workspace --all-targets --lockedto pass. Managed version requirements are exact=VERSIONpins, so an explicitly selected release cannot silently resolve a later patch/minor release.cargo fixresolves the candidate lockfile; the final check must use that same resolution. A failed gate restores the snapshot by default;--keep-on-failureis explicit, and--restorecan recover a persisted, path-validated snapshot after an interruption. Process fixtures upgrade v12 origins, reject retired pre-v12 origins, prove restoration across multiple workspace members, preserve a failed edit only when explicitly requested, restore that persisted review state, and reject symlinked Rust sources before starting the transaction. 12.1.0 recovery hardening: automatic and persisted restores validate the complete bounded index and every snapshot/target before staging all file replacements. Limits are 8 MiB/index, 100,000 entries, 64 MiB/file and 512 MiB/restore. Symlinks/reparse points, malformed or duplicate entries and non-regular files are rejected. Per-file replacement does not truncate a hardlinked destination. A later apply error may leave earlier files restored; it reports progress and retains the backup. Stop other writers first: this is neither an all-files atomic commit nor protection from hostile concurrent filesystem changes. Platform acceptance is scoped to the published release evidence. -
🟠
[Manual Application Boundary]the command never installs a CLI, changes secrets, executes database migrations, invents authorization or tenant policy, exposes Nexus/Studio, validates providers, or declares an application production-ready. Database restore/migration/rollback, the full test suite, authorization negatives and deployment smoke tests remain mandatory human-owned gates.Update notice (13.0.0-alpha.4): the CLI never downloads, verifies or replaces its own binary; a newer one is installed with
cargo install cargo-rullst --version =X --locked. At most once a day, in an interactive terminal (every standard stream a terminal, notCI,RULLST_DISABLE_UPDATE_CHECKorCARGO_NET_OFFLINE), the CLI prints that exact command when crates.io has a newer non-yanked release of its major: a stable release, or for a prerelease CLI also a newer prerelease; never build metadata or another major. The notice reads only the private catalog cache and is shown on the home screen or on standard error after a command. A background thread refreshes that cache at most once a day with one fixed HTTPS request (no redirects, 256 KiB cap, one four-second deadline); a failure is silent. Daily stamps in the user cache (rullst-ui-v1/) record the last notice and the last request. The cache keeps the 12.1.0 Unix and Windows private-directory checks (caller-owned0700directory or protected DACL, single-link private files, no-follow bounded reads, non-blocking staged replacement) and reuses a catalog for at most 24 hours. It grants no installation, project or deployment authority.Native CLI artifact preparation: the candidate pipeline builds both CLI entry points on the four targets in
.github/cli-artifact-targets.jsonand runs each executable’s version check on its native host. A boundedrullst.cli-artifacts.v1inventory binds file names, sizes and SHA-256 digests to version, target, build runner, repository and source commit. Ordinary CI inventories have no release tag and cannot be promoted to release artifacts. These checks establish file integrity, not publisher authenticity. Trusted tag-workflow provenance, client verification, staged installation, recovery and application acceptance remain separate required boundaries; no build or discovery command installs these files automatically. The tag-only release pipeline now calls the same native builder after exact protected-main admission. A separate job with signing authority checks the downloaded checksums and attests executables, manifests and inventory files without checking out source or executing downloaded binaries. GitHub release assets include those files only after crate publication and attestation pass. The development release workflow also retains the original Sigstore bundle and its signed in-toto envelope as release assets and verifies every artifact against it with exact repository/workflow/tag/commit identity before release creation. Releases from 12.1.2 on, including the 13.0.0 prereleases, carryrullst-release.sigstore.jsonandrullst-release.intoto.jsonl; earlier release assets remain unchanged. See the OpenSSF evidence plan. Client-side verification must pin the publisher, tag workflow, source tag and commit and then compare the expected platform/version/file digest; metadata and matching checksums by themselves remain insufficient authority.Self-updater removed (13.0.0-alpha.4):
update check,update verify,update stageandupdate install review|apply|recover(12.1.0 to 13.0.0-alpha.3) were removed with their download, provenance, installation receipt, rollback and pruning machinery. The native CLI executables and theirrullst.cli-artifacts.v1manifests remain attested GitHub release assets that users verify by hand (see SECURITY.md, “Verifying a release”).Guided update composition:
update guided --to EXACT_VERSIONis an opt-in interactive composition of the project commands (prepare, verify, review, apply). It reuses the selected version, directories and review digest. Show each complete review before a default-no approval; distinguish trusted project execution/network access and original-file application. No blanket approval or noninteractive implicit yes is accepted. Declining stops before the next operation, preserving any already completed step and its recovery evidence. The current CLI’s versioned migration rules remain authoritative, so--tomust be in its major; since 13.0.0-alpha.4 the flow installs no CLI (--scope,--root,--allow-majorand--prereleasewere removed). Existing structured commands remain the automation interface. Report elapsed time per executed stage, excluding user input; do not promise instant upgrades.Isolated project preparation (working source): the opt-in
update project preparecommand snapshots tracked and non-ignored untracked files from the selected Git working directory into private caller-owned storage, preserving uncommitted source contents and deletions. Links, special files, unsupported paths and oversized inputs fail before migration. The copy is bounded to 100,000 entries, 64 MiB per file and 512 MiB total. Cargo metadata and dependency planning run only inside that copy, with Cargo network access and Rustup auto-installation disabled. The existing exact-version manifest editor and the versioned catalog records its review report; preparation executes no build scripts, procedural macros or application tests and grants no apply authority. Ignored files, including typical secret files and build outputs, are not copied, except the rootCargo.lock(legacy generators ignored that reproducibility input). Version requirements and existing locked Rullst packages must not imply a downgrade; unsupported or ambiguous requirements require manual review. This is a source snapshot, not a filesystem sandbox: later verification must explicitly authorize trusted project execution. Bounded reviewed application/recovery have separate explicit consent and acceptance gates.Candidate verification (12.1.0):
update project verifyreloads the private preparation, validates its baseline/current source/candidate against bounded records and recomputed migration plans, rejects stale inputs, and takes an exclusive operation lock.--dry-runshows the selected commands without running builds/tests. Execution requires--allow-project-codeand uses another fresh private copy. Cargo resolves the lockfile; every locked managed Rullst package must match the exact target before checking all workspace targets and running workspace tests with--lockedand the selected feature policy. Default features are the default policy, with explicit all/custom/no-default selection. Cargo is offline unless separately authorized; an offline environment cannot be overridden. Rustup auto-installation remains disabled. Build outputs live outside the verified source tree. Each tool has a bounded deadline (900 seconds by default, at most 3,600) and at most 8 MiB per output stream. Execution reuses the supervised child/process-group cleanup and responds to cancellation. This is best-effort process-tree cleanup, not containment of hostile descendants. Private logs include Cargo/rustc version probes, commands and their output digests. Configured compiler wrappers or alternate toolchains still require operator review; probes are not toolchain attestations. Original/prepared/baseline changes, unresolved must-change migration findings (review findings are only reported), failed commands and unexpected source writes reject acceptance. Only the candidate root lockfile may change during verification.rullst.project-verification.v1records successful commands, feature policy and final file digests, grants no application/deployment authority, and is not a reusable apply token. Native acceptance and complete application/recovery remain release gates. Tests execute trusted project code with the caller’s environment: the copy is not a sandbox, and external effects cannot be reversed through source-file recovery.Explicit candidate review (working source):
update project reviewrevalidates the private preparation, verified file inventory and bounded successful command logs under both operation locks, then shows the complete manifest/lockfile diff and a SHA-256 digest binding that review. Git external diff/text-conversion helpers and paging are disabled; the diff is bounded to 8 MiB and no build/test or original-file edit occurs. The review digest grants no application authority. Native acceptance remains required.Reviewed application/recovery (12.1.0):
update project apply --verified PATH --approved-review SHA256requires the exact review digest and fresh source validation under both preparation locks and a canonical-source lock in the configured private cache. Only the reviewed workspace manifests and root lockfile are eligible. Stage all replacements before changing originals, preserve original permissions and replace directory entries without truncating hardlinks. Unix mode/owner/group and Windows owner/group/DACL/integrity label enter the review digest, with a 1 MiB aggregate serialized access-policy limit. Unix extended ACLs/xattrs and special mode bits require manual updates, except the SELinuxsecurity.selinuxlabel that the staged same-directory replacement receives anyway; Windows read-only/special attributes, alternate streams, resource/central-access policies and access policies that cannot be recreated exactly fail before source writes. Darwin extended ACLs are inspected through a narrowly scoped OS FFI module, since they are separate from xattr names. It owns and frees the returned ACL and never changes it; a native ACL regression and the exact unsafe-source allowlist govern this exception. The API follows Apple’s ACL entry contract. Staging installs the access policy before writing candidate contents. Keep a durable bounded intent record and before/after digests for interruption recovery. Recovery must refuse divergent user edits and may restore only files from that intent; absent original root lockfiles may be removed. Per-file replacement is not a single atomic workspace commit.update project recoverwith the same approval accepts only the recorded before/after states and supports repeated recovery. Locks coordinate this CLI under the same cache configuration, not editors, other caches or filesystem aliases. Stop other writers first; hostile concurrent renames, external test effects and databases remain outside file recovery. Forced process termination during staging can leave disposable sibling temp files; the private before/verified trees and intent must be retained. Timestamp, Windows audit-policy preservation and power-loss fault acceptance remain outside this implementation. Published acceptance covers only the declared platform and fault scenarios. Local process tests cover a killed per-file commit with a persisted intent and subsequent engine recovery, plus real CLI staging terminated by Linux’s file-size limit without changing originals. They do not establish power-loss durability.
📱 13. Installable Applications: Progressive Web App Contract
Since 13.0.0-alpha.3 an application is made installable as a Progressive Web App. The canonical product stays the Rullst web application; the browser, or a store package of the same site, only installs it.
-
🟢
[Implemented / Bounded]cargo rullst make:pwa: for an HTML starter it writesstatic/manifest.webmanifest(idandscope/,displaystandalone,name,short_name,start_url,theme_color,background_color,lang, PNG icons 192 and 512 plus a maskable 512), four placeholder icons pre-rendered from the Rullst logo (no image library),static/pwa.js,src/pwa.rs, the workersrc/pwa/sw.jsandPWA.md. Values come from validated flags or the Cargo package name. It mountscrate::pwa::router()after the crate root’s single.merge_axum(rullst::health::health_router())(merges inside areturnexpression are fallback routers and are ignored), adds the head tags to every recognized layout insrc/(anhtml!document, a document in a string literal, Core’srender_page_with_lang, rewritten torender_page_with_head) and declaresmod pwainsrc/lib.rs, elsesrc/main.rs. The complete plan is validated before the first write: identical files and wired places are skipped, so a repeated run changes nothing, and a generated file with other content (edited, or rendered from other flags) refuses the run. Projects without an HTML layout (the JSON API starter), without the health merge or with a source file that does not parse are refused. -
🟢
[Implemented / Bounded]Routes and headers:/sw.jsanswerstext/javascript; charset=utf-8withCache-Control: no-cachefrom the site root, so the worker’s scope is/; its cache is named after the package version./offlineis anhtml!page withno-cache. Both answer GET only and are mounted outside the CSRF layer. Static files keep Core’s policy (no-cache, or a year-longimmutablefor content-hashed names) and the manifest Core’sapplication/manifest+jsontype. -
🟢
[Implemented / Bounded]CSP compatibility: the registration script is a same-origin file allowed byscript-src 'self', it styles the update prompt only through the DOM, the worker is allowed byworker-src 'self', and the manifest by thedefault-src 'self'fallback. No inline script or style is added. A policy with'strict-dynamic'needs the request nonce on the script. -
🟢
[Implemented / Bounded]Worker storage rule: it precaches/offline(required) and the PWA’s own and the starter’s static files (when present); navigations and unversioned static files are network-first with a stored-copy or offline fallback; content-hashed static files are cache-first; other requests pass through. It stores only same-origin200basicresponses to GET requests whoseCache-Controlhas neitherno-storenorprivateand whoseVaryis not*; since Core answersno-storeby default, authenticated pages are not kept. A waiting worker takes over only after the user accepts the page’s reload prompt (SKIP_WAITING); the old version’s cache is then deleted.node cargo-rullst/src/generators/pwa/assets/pwa_tests.cjsexecutes both scripts against in-memory browser APIs in CI, and a generated Blank application is compiled and checked for these headers. -
🟢
[Implemented / Bounded]Google Play (Trusted Web Activity):make:pwa --android --host <host> --package-id <id>also writes the Bubblewrapandroid/twa-manifest.jsonfrom the same validated values (packageId,host,name,launcherName,startUrl,iconUrl,maskableIconUrl,webManifestUrl,fullScopeUrl, theme, navigation and background colours,display,appVersion/appVersionCode1,enableNotifications: falseand a placeholdersigningKeyoutside the repository) andPWA-ANDROID.md. The host is a lowercase DNS name with an alphabetic top label; the package ID has lowercase segments and may not becom.example.*.rullst::pwa::AssetLinksserves/.well-known/assetlinks.json(application/json,public, max-age=3600) with onedelegate_permission/common.handle_all_urlsstatement perAndroidApp; package names are Android application IDs and each fingerprint is 32 colon-separated hexadecimal bytes (normalized to upper case), validated at construction with the typedAssetLinksError.AssetLinks::from_projectreadsRULLST_ANDROID_ASSET_LINKSfrom the process environment, then.env, then[[android.asset_links]]inRullst.toml; parse errors report only a line and column. With no app the router has no route. The generatedsrc/pwa.rsmounts it and reports an invalid configuration at startup instead of serving it. Bubblewrap, the signing key, Play App Signing and store review remain application work. -
🟢
[Implemented / Bounded]render_page_with_head: Core’srender_page_with_langwith extraMarkupat the end of<head>; an htmx request still receives only the content. -
🟠
[Application Boundary]icons, HTTPS, offline data and conflict handling belong to the application. Browser-side offline data follows the server-authoritative pattern of the PWA guide (an IndexedDB outbox replayed with idempotency keys); no sync engine is generated. -
🟠
[Documented / External Tools]Other stores: the PWA guide documents packaging the same site as MSIX for the Microsoft Store with PWABuilder and a Partner Center reservation, as Microsoft documents it, and installation on iPhone and iPad from Safari. No Windows or App Store package is generated or verified by the framework; App Store Review Guideline 4.2 usually rejects an app that only wraps a website. -
🟢
[Implemented / Bounded]Shared Wire Contract:rullst::client_contractprovides the strictrullst.clientv1 JSON marker, positive version negotiation, private typed request/success/failure envelopes, bounded log-safe correlation and idempotency tokens, server time, message-free dotted failure codes and a codec capped at 2 MiB. Unknown outer fields, unsupported versions and oversized bodies fail closed. The same code compiles natively and forwasm32-unknown-unknown. The envelope carries no role, tenant or authorization claim; the server must derive those from its authenticated context and persist idempotency atomically. -
⚪
[Removed in 13.0.0-alpha.3]Omni:cargo rullst make:omni,cargo rullst omni(includingomni android --release), the Tauri shell templates, their three release workflows and the native-onlyoffline-syncfeature (rullst::offline_sync) were removed. The shells exposed no native API to the site, so an installed PWA gives the same result.cargo rullst upgradereports an Omni shell or script (V13-OMNI-REMOVED) and a manifest that enablesoffline-sync(V13-OFFLINE-SYNC-REMOVED); see the migration guide.
💡 Rullst CLI - Full Command Reference
The Command Line Interface (cargo-rullst) scaffolds projects, invokes build
tools, and provides bounded static-analysis and deployment helpers.
The CLI’s --help output is authoritative for the installed version. This page
documents the principal commands of the version 13 prerelease CLI and their
security boundaries. Cargo never installs a prerelease unless you name it, so
cargo install cargo-rullst installs the end-of-life 12.x CLI; install this one
with an exact version:
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
cargo-rullst is a Core package. Two commands carry their own Maturity
label below the command name, Experimental: make:iot and green. The
maturity tiers page explains what the label
promises.
Machine-readable output
A command whose result is data accepts --json. The contract is the same for
all of them:
- Standard output carries exactly one JSON document and nothing else: no progress lines, banners, tables or ANSI escape codes, whether or not the terminal supports colour.
- The document has a top-level
schemafield naming its shape and version,rullst.<name>.v1. A new field is added without a new version; a removed or renamed field, or a changed meaning, getsv2. Documents that predate this contract also keep theirschema_versionfield, with the same value. - Human messages, progress and failures go to standard error. Exit statuses are
the same with and without
--json: a failed check or a refused request still exits non-zero, and the document is still printed when the command could compute one (doctor,deploy:doctorandacademy:doctorprint their report and then exit with status 1 when a check fails). - A request that has no JSON form (for example
pkg add --json) is refused on standard error with a non-zero exit status and prints nothing on standard output.
| Command | schema |
|---|---|
cargo rullst info --json | rullst.cli-info.v1 |
cargo rullst doctor --json | rullst.cli-doctor.v1 |
cargo rullst audit --json | rullst.cli-audit.v1 |
cargo rullst inspect routes --json | rullst.cli-routes.v1 |
cargo rullst inspect models --json | rullst.cli-models.v1 |
cargo rullst inspect schema --json | rullst.cli-schema.v1 |
cargo rullst pkg list --json | rullst.cli-pkg.v1 |
cargo rullst upgrade --dry-run --json | rullst.upgrade-plan.v1 |
cargo rullst deploy:doctor --json | rullst.deployment-diagnostic.v1 |
cargo rullst academy:doctor --json | rullst.academy-diagnostic.v1 |
cargo rullst update project prepare --json | rullst.project-preparation-result.v1 |
cargo rullst update project verify --dry-run or --json | rullst.project-verification-plan.v1 (dry run), rullst.project-verification.v1 |
cargo rullst update project review --json | rullst.project-review-report.v1 |
cargo rullst update project apply --json and recover --json | rullst.project-application.v1 |
cargo rullst green --json (experimental) | rullst.green.v1 |
pkg list --json prints {"schema": "rullst.cli-pkg.v1", "table": "dependencies", "packages": ["rullst-auth"]}; table is
workspace.dependencies for a virtual workspace manifest. audit --report json
is a different interface: it writes SECURITY_REPORT.json
(rullst.cli-audit-report.v1) and cannot be combined with --json. The
files cargo rullst generate:ai-context writes (rullst.project-context.v1)
are described in project context. db:status is
printed by your application, not by the CLI, and has no --json form yet.
🏗️ 1. Project Initialization & Maintenance
cargo rullst (no arguments): home screen
Running the CLI without a subcommand opens the v13 home: the RULLST wordmark in a blue → green → orange gradient, the slogan line with the installed version, and a summary of where you are.
- Inside a Rullst project (the current directory or a parent has a
Cargo.tomlwhose package depends onrullst): the package name, the enabledrullstfeatures (defaultwhen default features are on), the database family and where it is configured (the processDATABASE_URL, then.env, then[database].urlinRullst.toml;TURSO_DATABASE_URLfor a Turso-primary project), the number ofsrc/migrationsfiles and the latest one, and the Git branch. Connection URLs are never displayed. The menu leads with dev, dash, scaffolding, database,doctorand deploy, keeps every project operation of earlier releases, and offers Green Report (cargo rullst green, Experimental). Started from a subdirectory, menu commands run at the project root (exceptnew). The migration count is the files on disk; runcargo rullst db:statusfor the applied state. - Outside a project: the summary links the docs (start here and this reference) and the menu leads with project creation, followed by the operations for an existing project.
Both menus include Search All Commands, a fuzzy command palette over every
command, including the ones attached at runtime (update ..., make:privacy,
deploy:doctor, completions, …). Type to filter by name, alias or
description; every word must match, names rank above descriptions, and ties go
to the shorter name, then alphabetical order. ↑/↓ (or Tab, Ctrl+P/N) move,
Enter runs the selection (asking first for its required arguments, as a menu
when the values are fixed), Esc returns to the home menu and Ctrl+C exits.
cargo rullst --help is the non-interactive equivalent.
The opening animates for about 0.7 s on every run; any key skips the
animation. RULLST_OPENING=daily animates only the first run of each day
(later runs draw the final frame instantly) and RULLST_OPENING=static never
animates. For daily, the day of the last animation is kept in the user cache
directory
($XDG_CACHE_HOME/rullst-ui-v1 or ~/.cache/rullst-ui-v1;
%LOCALAPPDATA%\rullst-ui-v1 on Windows), never in the project. If that file
cannot be read or written, the opening is static.
Colours use 24-bit RGB when COLORTERM is truecolor or 24bit and the
nearest xterm 256-colour entry otherwise. RULLST_REDUCED_MOTION=1 (or
true/yes) keeps the colours without animation. NO_COLOR prints the plain
line RULLST v<version> · SECURE, FAST AND AI-NATIVE RUST FRAMEWORK while the
menu stays interactive. When standard input, output or error is not a
terminal, CI is set or TERM=dumb, the CLI prints the plain line, the
summary and the equivalent commands, then exits successfully without
prompting.
In a terminal, at most once a day, the home (or any command, on standard
error after it finishes) prints A newer cargo-rullst is available: cargo install cargo-rullst --version =X --locked when crates.io has a newer release
of the same major; the catalog is fetched in the background at most once a day
and cached privately, and nothing is installed. RULLST_DISABLE_UPDATE_CHECK=1,
CARGO_NET_OFFLINE=true or CI=true turns it off. See
Update notice and installing a newer CLI.
Errors and exit codes
Every failure is printed on standard error by one shared report:
error: Not inside a Rullst project
What happened `cargo rullst make:model` must run at the root of a Rullst project; ...
How to fix The project root is `../..`: run `cd ../..` and try again.
Docs https://rullst.github.io/Rullst/book/start-here.html
The common failures have their own title and fix: not inside a Rullst project
(pointing to an enclosing project root when there is one), a missing tool
(cargo, rustc, rustup, git, docker, wasm-bindgen, kubectl or
npm), a missing Rust target (rustup target add <target>), an unreachable
database, a port already in
use, permission denied, invalid input, network failures, an interrupted
prompt and a starter that moved to the repository’s examples (new --blueprint lms|blog|portfolio|erp). Anything else gets a generic report naming the failed command.
Connection-string passwords (up to the host, even when they contain an
unencoded @), *_KEY/*_SECRET/*_TOKEN/password values (also under the
quoted keys of JSON or Debug maps), bearer tokens and well-known credential
formats (including Stripe sk_live_/whsec_ secrets) are masked, and
terminal control characters are replaced, in every line of the report. The
whole message is redacted before a long one is cut to 4,096 characters.
A command started from the home menu or the command palette prints its own
report, and cargo rullst then exits with that command’s status (for example
2 for a usage error) without a second report.
-v/--verbose (accepted by every command) adds the underlying causes and
the error’s Debug form, still redacted. A panic prints a short “Internal
error” report instead of the default panic message and backtrace unless -v
or RUST_BACKTRACE is set.
| Exit status | Meaning |
|---|---|
0 | Success (doctor: no failed check; warnings are allowed). |
1 | The command failed, including doctor with a failed (✗) check. |
2 | Usage error: an unknown command, invalid arguments or an argument that is not valid UTF-8. |
An unknown command lists the closest commands at the level where it was typed
(cargo rullst make:modek suggests make:model; cargo rullst update projetc
suggests update project; cargo rullst db lists the db:* group). Ranking
ignores case and separators (make-model), then uses edit distance with
transpositions, an exact group:name segment (migrate) and prefixes.
Colour follows the home screen rules on each stream: none for NO_COLOR, CI,
TERM=dumb or a stream that is not a terminal. When standard output is not
coloured, generator output is plain as well. Long steps (the first build of
new, the Cargo gates of upgrade) animate a spinner only when standard
output is a terminal without NO_COLOR, CI, TERM=dumb or
RULLST_REDUCED_MOTION; otherwise, as in CI logs and pipes, they print one
plain line without escape sequences or carriage returns.
After a generator succeeds, the CLI prints up to three concrete Next steps
(for example cargo rullst db:migrate after make:model -m). Generators that
already print their own instructions (new, auth, deploy,
make:k8s) add none, make:migration:auto adds one only when it wrote a
migration, and --json invocations never print them.
cargo rullst completions <shell>
Prints a completion script for bash, zsh, fish, powershell or elvish
generated from the complete command tree, runtime-only commands included.
Scripts complete the rullst executable that cargo install cargo-rullst
installs beside cargo-rullst (Cargo’s own completion does not delegate the
arguments of external subcommands); --bin cargo-rullst targets the other
executable.
cargo rullst completions bash > ~/.local/share/bash-completion/completions/rullst
cargo rullst completions zsh > ~/.zfunc/_rullst # with fpath+=(~/.zfunc)
cargo rullst completions fish > ~/.config/fish/completions/rullst.fish
cargo rullst completions powershell >> $PROFILE
cargo rullst info [--json] (alias version)
Prints the CLI version, platform, rustc/cargo versions and the detected
project (name, root, rullst requirement, features, database family and
source, migration files, Git branch). It reads local files and runs
rustc --version/cargo --version only; connection URLs are never printed.
--json prints:
{
"schema": "rullst.cli-info.v1",
"schema_version": "rullst.cli-info.v1",
"cli": { "name": "cargo-rullst", "version": "13.0.0" },
"platform": { "os": "linux", "arch": "x86_64" },
"toolchain": { "rustc": "1.98.1", "cargo": "1.98.1" },
"project": {
"name": "shop",
"root": "/work/shop",
"relative_root": null,
"rullst": "13",
"features": ["default", "orm"],
"database": { "status": "configured", "kind": "SQLite", "source": ".env" },
"migrations": { "count": 2, "latest": "m20260101000000_create_users" },
"git_branch": "main"
}
}
project is null outside a project; toolchain values are null when the
tool is missing. database.status is configured, not_configured or
unreadable (with reason); rullst is the version requirement, or path,
git or workspace.
cargo rullst new <name>
Creates a Rullst project from scratch. Since version 12, new intentionally
generates one audited application architecture: Active Record for
database-backed code and server-rendered html! views enhanced with HTMX for
full-stack pages.
In a terminal, cargo rullst new (with or without a name) runs the v13
create wizard; the home screen’s Create New Project entry runs the same
wizard. It asks, one screen at a time:
- the project name, when none was given (letters, digits,
_and-, starting with a letter; Rust keywords and existing paths are refused). A name given on the command line is checked by the same package-name rules before the first question; - the starter — Blank (a server-rendered web page with HTMX), API (the Blank starter as a headless JSON API) or SaaS (authentication, Stripe billing and a pricing page) — each with a one-line description and a compact preview of the file tree it generates. The preview is rendered by the real project writers into a private temporary directory that is removed immediately; nothing is built and no network is used;
- the primary database (Turso and No database only for Blank and API);
- optional features (zero or more): AI, Redis adapters, Dockerfile and the Turso, MongoDB, DuckDB, SurrealDB and Qdrant add-ons;
- a review of the answers, the file tree and the commands
newwill run (the initial migration, unless skipped or database-free), with Create the project, Back and Cancel.
↑/↓ (or j/k, or a digit) moves, Space toggles a feature, Enter confirms
and Esc, Backspace or ← returns to the previous question. On a terminal too
short for every choice, the list scrolls with the highlighted entry and counts
the hidden ones. Cancel and Ctrl+C
create nothing. Colours follow the home screen: NO_COLOR removes them and
24-bit colour needs COLORTERM=truecolor. Every question has a flag; a
question answered by a flag is skipped. When standard input, output or error
is not a terminal, CI is set or TERM=dumb, new never prompts: without
--default it exits with an error that names the flags instead.
After creation, new prints numbered next steps: cd <name>,
cargo rullst dev (which applies pending migrations) and the URL of the
generated welcome page (http://127.0.0.1:<port>, where the port comes from
PORT, the project’s .env, [app].port in Rullst.toml, then 3000; JSON
APIs show a curl command instead).
The choices that materially change the generated application:
- Starter: Blank, API and SaaS (
--blueprintvaluesblank,apiandsaas;apiis the Blank blueprint with--api). The LMS, Blog, Portfolio and ERP blueprints left the CLI in 13.0.0-alpha.4 and are now complete applications in the repository’sexamples/directory; see Starters that moved to the examples. - Persistence: a primary relational backend (SQLite, PostgreSQL, MySQL, MariaDB, or bounded Turso-primary for blank/API) plus optional Turso/libSQL, MongoDB, DuckDB, SurrealDB, and Qdrant capabilities. The optional selector accepts zero or more choices and omits capabilities already selected by the primary profile or flags. Specialized adapters remain separate from SQLx Active Record.
- Application profile: the Blank and SaaS starters use the audited
html!SSR/HTMX path; API (--api) uses the headless JSON path. Repository, LiveView, Wasm Island, Pico.css and Tera foundations remain application-owned APIs and are not presented as equivalent generated profiles. - Arguments:
<name>: The folder and package name (e.g.,my_startup).
- Optional Flags:
--api: Scaffolds a headless JSON API from the Blank starter (no HTML view rendering), the same as--blueprint api;--blueprint saasrejects it instead of ignoring it. Its example write route,POST /api/messages, is a bearer machine endpoint: clients sendAuthorization: Bearer <API_TOKEN>with the randomAPI_TOKENwritten to.env, and the server does not start without it (see which security layer to use). The interactive wizard then skips its starter question instead of letting its Blank default replace the flag.--docker: Adds a multi-stageDockerfileand.dockerignore. The.dockerignoremirrors the generated.gitignore: it excludes.envand.env.*(except.env.example), SQLite and DuckDB files (*.db,*.sqlite,*.sqlite3,*.duckdband their journals, one list shared by both files) and the host-local.cargo/config.tomldescribed below, so the builder’sCOPY . .never sends them to a (possibly remote) builder. An existing.dockerignoreis kept unchanged. Since 13.0.0-alpha.3 the dependencies are built in their own cached layer without an extra tool: aplannerstage copies only the manifests,Cargo.lockandrust-toolchain(.toml), withfn main() {}standing in for every target (src/main.rs,src/lib.rs,src/bin/*.rs, explicitpath = "*.rs"targets) and build script, and the builder runscargo build --release --lockedon that skeleton beforeCOPY . ., touches the real.rsfiles and builds again with--locked. Docker reuses the dependency layer until a manifest, the lockfile or the set of targets changes; workspace members keep their paths. Since v13 the runtime stage isgcr.io/distroless/cc-debian12:nonroot(glibc, libgcc, libstdc++, CA certificates and tzdata; no shell or package manager). It runs as UID/GID 10001 withHOME=/app, sets the production bind address and copies local static/config assets when present. The builder stage prepares the/apptree, so an explicit SQLite selection still uses the writable/app/datadirectory. Without a shell the image has noHEALTHCHECK; let the platform probe/healthand/readyover HTTP. The migration job runs the binary directly (/app/<name> db:migrate). The image size of this template has not been measured yet; measure it withdocker image lsin CI or on a machine with Docker. Secrets are never embedded and no anonymous volume is declared. Run schema migrations as one deployment job before starting or rolling multiple replicas; the generated image deliberately does not race migrations from every application process. Compose services, persistent-volume ownership, backup/restore and platform deployment hardening remain explicit project work. Every starter mountsrullst::health::health_router(), so the/healthand/readyprobes used bymake:k8smanifests and thedeployCaddyfile answer200without authentication.--turso: Adds Turso/libSQL as a side store beside the primary database: the direct Hrana HTTP v3 adapter, checked migrations and its real-SQL offline development fallback. It does not imply transparent replication.--mongodb: Adds MongoDB as a side store: typed document CRUD and its deterministic offline store.--duckdb: Adds DuckDB as a side store for in-process analytics; the optional native dependency increases the first build time.--surrealdb: Adds SurrealDB as a side store: HTTP document CRUD and bounded read-only graph queries.--qdrant: Adds Qdrant as a side store for bounded dense-vector operations and generates empty/mock_*-compatible environment fields; it is not the SQL primary.--nixand--buildahwere removed in 13.0.0-alpha.4; see Advanced deployment.--default: Skips every question and uses deterministic defaults for anything no flag sets (nameapp, Blank, SQLite, no features), intended for CI and reproducible scaffolding.--blueprint <blank|api|saas>: Selects the starter; the wizard skips that question.lms,blog,portfolioanderpstill parse but fail before anything is written, naming the example that replaced them (exit status 1).--database <sqlite|postgres|mysql|mariadb|turso>: Selects the primary database; the wizard skips that question. Network databases must be configured before migration bootstrap. The SQLx backends (sqlite,postgres,mysql,mariadb) work with every starter.tursois accepted only with the Blank starter (web page or--api) and has no Nexus, Studio database pages,Schemamigrations or SQLx ORM; the wizard’s choice,--helpand the review/dry-run screen say so. See Database Support.--no-database: Generates the Blank or API starter without a primary relational database; it conflicts with--databaseand--blueprint saasrejects it. Such a project gets noDATABASE_URL, no emptyrullst.dbfile (only SQLite projects get one) and, with--docker, no SQLiteDATABASE_URLin its Dockerfile.--ai: Enables the umbrella AI facade in the generated manifest and, with the SaaS starter, the Nexus AI assistant (nexus-ai, since 13.0.0-alpha.4); without it the SaaS starter’s Nexus has no assistant and norullst-aidependency.--redis: Enables the umbrella Redis queue and cache capabilities and the Redis-structures side store (the direct ORM Redis feature).--skip-initial-migration: Generates the project without running the best-effort initial database migration.cargo rullst devapplies pending migrations when it starts; runcargo rullst db:migrateexplicitly otherwise.--dry-run(v13): Prints the plan (the answers, a compact file tree with the exact number of files and the commands it would run) and exits without creating anything. With--defaultit never prompts; in a terminal without--defaultthe wizard’s review ends with Finish the dry run.--vcs <git|none>(v13, defaultgit): Likecargo new, initializes a Git repository in the new project whengitis installed and the destination is not already inside a Git work tree;noneskips it. The generated.gitignorekeeps.env,.env.*(except.env.example) and/targetout. Without Git,newprints a warning and keeps the project;cargo rullst auditchecks committed secrets through Git.
Since v13, --blueprint, --database, --no-database, --ai and --redis
no longer require --default: in a terminal they answer their questions and
the wizard asks the rest. --api selects the API starter; --no-database and
--database turso limit the starter question to Blank and API. The retired
--hot-reload still requires --default and is rejected.
When the generating Linux host has mold or lld, the project’s
.cargo/config.toml selects it to speed up local linking. That file describes
the generating machine only: the generated .gitignore and .dockerignore
exclude it, so CI runners, teammates and the container builder (which has
neither linker) build with the toolchain default.
Since v13 the generated Cargo.toml also has a [profile.release] with
lto = "thin", codegen-units = 1 and strip = "symbols"; panic stays
unwind so one panicking handler cannot stop the server. Release builds take
longer and their backtraces lose symbol names; the measured release defaults
state the size and build-time effect on one starter. Since 13.0.0-alpha.4 it
has a [profile.dev] as well: the application keeps line tables (file:line in
panics and backtraces) and dependencies are built without debug information,
which shortens debug builds and rebuilds; see Build times.
Without --skip-initial-migration, project creation performs the first Cargo
build before applying migrations. A clean first build can take several minutes,
especially for the larger SaaS profile; the animated status remains visible
while Cargo is working. Later migration and server runs reuse that project-local
build cache.
Preview a starter without writing anything:
cargo rullst new shop --default --blueprint saas --dry-run
For example, the release gate can generate a SaaS starter without prompts or network-dependent bootstrap work:
cargo rullst new packaged-saas --default --blueprint saas --skip-initial-migration
A deterministic profile can pin the starter, database and optional features:
cargo rullst new operations-portal --default --blueprint saas \
--database mariadb \
--ai --redis --skip-initial-migration
Every generated manifest also declares validator 0.21 with its derive
feature. #[derive(rullst::Validate)] expands to ::validator::… paths that
only a direct dependency resolves, so ValidatedForm/ValidatedJson DTOs
compile without adding it by hand; Rullst Core already builds that version.
Generated SQLx applications disable the default features of both the umbrella
rullst dependency and the direct rullst-orm dependency, and select exactly
one strict primary profile (strict-sqlite, strict-postgres, or
strict-mysql; MariaDB uses the MySQL protocol). This prevents an implicit
SQLite default from masking the chosen backend and keeps the other drivers,
including bundled SQLite, out of the build. Turso-primary projects enable
rullst-orm’s turso feature (which includes SQLx’s SQLite driver), and
database-free projects depend on neither the ORM nor a SQLx driver.
Generated-project verification boundary
The repository does not treat template rendering as sufficient evidence. A
structural contract materializes 10 internal starter/profile shapes (five
public directly linked layouts plus five legacy DLL layouts retained for
regression) and checks paths, Rust syntax and manifests. A slower four-case
set crosses every starter, hot and non-hot layouts, database/API boundaries and
a SaaS release build, runs every generated test target, and constructs the
public router of each hot-reload project using offline-safe defaults. A
separate seven-case test invokes the public cargo rullst new binary and
verifies exact feature selection for SQLite, PostgreSQL, MySQL, MariaDB, AI,
Redis, Turso, MongoDB, DuckDB, SurrealDB and Qdrant across the Blank, API and
SaaS starters plus a polyglot profile. The invocation starts outside the source checkout, proving
that an unpublished pre-release CLI retains its exact matching checkout as a
path source instead of requesting unavailable registry packages.
The public polyglot profile uses cargo check in that CLI-level set because a
second bundled-DuckDB test build adds no adapter behavior and can consume
several GiB on small machines. DuckDB, MongoDB, SurrealDB, Turso and Qdrant
runtime behavior is exercised by their dedicated ORM matrices instead. These
gates prove reproducible local generation and bounded offline construction;
they do not prove provider accounts, production deployment, browser behavior
or application-specific authorization.
Starters that moved to the examples
Since 13.0.0-alpha.4 cargo rullst new offers three starters: Blank, API and
SaaS. The other blueprints left the CLI and live on as complete applications
in the Rullst repository, tested in its workspace:
Former --blueprint | Example | What it shows |
|---|---|---|
lms | examples/lms | Course catalog, modules and lessons, accessible player, enrollment, owner-bound progress, sign-in and a Nexus admin |
portfolio | examples/portfolio | A developer portfolio edited in Nexus |
erp | examples/erp | Products, stock movements and orders behind one administrator policy |
blog | examples/blog | The blog showcase (a larger application than the old starter) |
--blueprint lms, blog, portfolio or erp still parses but fails before
anything is written: the report names the example directory and its GitHub URL
and exits with status 1. The examples are applications to copy from, not
supported starters: there is no generator or upgrade path behind them. Each
README says how to run it and how to switch its workspace dependencies to the
published crates. Applications generated earlier from these blueprints keep
working: their code is theirs.
cargo rullst tour (v13)
A short guided walkthrough of the main commands in seven skippable steps:
new, dev, dash, make:*, db:*, doctor and generate:ai-context.
Each step explains what the command family does, lists commands to try and
offers one read-only example that runs only when you pick it:
cargo rullst new tour-demo --default --dry-run for new and --help for the
others. Next,
Previous (or Esc) and Quit the tour move between steps. The tour needs no
network and never creates or changes a project.
cargo rullst tour # guided, in a terminal
cargo rullst tour --list # every step and its example, without prompting
Outside an interactive terminal (pipes, CI, TERM=dumb) cargo rullst tour
prints the same list as --list and exits successfully.
cargo rullst upgrade
Plans or applies a transactional application upgrade. The target defaults to
the exact installed cargo-rullst version; --to <VERSION> accepts an exact
version in the same major release train as that CLI. The command fails before
writing anything when the target is older than a managed requirement’s lower
bound or than a Rullst package locked in Cargo.lock, so an older CLI never
downgrades the project; install a CLI that is not older than the project.
# Human-readable plan; no writes or dependency resolution
cargo rullst upgrade --dry-run
# Versioned machine-readable plan
cargo rullst upgrade --dry-run --json
# Apply every source rule, not only those introduced after the project's version
cargo rullst upgrade --dry-run --all-rules
# Backed-up apply + cargo fix + cargo check
cargo rullst upgrade
# Deliberately inspect a failed partial migration instead of auto-rollback
cargo rullst upgrade --keep-on-failure
# Recover a persisted snapshot, including after interruption
cargo rullst upgrade --restore target/rullst-upgrades/<run-id>
The CLI uses Cargo metadata to scope workspace manifests, preserves TOML
comments/order, updates normal, inline, workspace, target-specific and renamed
Rullst dependencies, and reports unversioned path/git entries. Before applying,
it snapshots workspace manifests, the root Cargo.lock, and Rust sources under
target/rullst-upgrades/; a failed Cargo gate restores them by default. The
reports use the rullst.upgrade-plan.v1 schema and include version-selected
source findings.
The v13 CLI’s rullst-upgrade-rules-v4 catalog (115 rules) parses Rust sources
(with syn; comments, doc comments and strings never match an API rule), Cargo
manifests and generated project files, and reports each finding as
MUST-CHANGE (a compile-breaking or API-shape change) or REVIEW (changed
behaviour of an API, feature or configuration in use) with a stable V13-*
code, file:line, a one-line message and the title of its row in the
v13 migration guide.
Findings do not stop the transaction; a failed Cargo gate names the
must-change findings and repeats the guidance of V13-CAPITAL-REMOVED and
V13-MAIL-REMOVED (removed APIs reported for review).
Each rule records the release that introduced its change. The plan reads the
project’s Rullst version (the oldest version a Rullst requirement admits or
Cargo.lock holds); for a project already on a 13.0 prerelease it applies only
the rules introduced after that version and prints how many earlier rules it
skipped and how many findings they would have added. --all-rules applies
every rule; a v12 project, or one whose version cannot be read, gets every
rule anyway.
update project verify/review/apply refuse a preparation with must-change
findings; preparations apply every rule. In JSON, each
source_findings element keeps path, line, code, severity (BLOCKER
or REVIEW) and message and adds kind, introduced_in, migration_row
and migration_url; the root adds finding_counts, unscanned_sources,
migration_guide and rule_selection (current_version, all_rules,
skipped_rules, hidden_findings). See the tutorial
and its rule classification.
Since 12.1, managed requirements use exact =VERSION pins. The final
cargo check --workspace --all-targets --locked validates the lockfile produced
by cargo fix without resolving a different version. Broader dependency ranges
remain an application decision after reviewing the update.
Process-level fixtures upgrade v12 origins, reject retired pre-v12 origins with
v12-first guidance, verify restoration across multiple workspace
members, retain a deliberately failed edit only with --keep-on-failure, and
restore that retained snapshot on demand. Symlinked Rust sources are rejected
before a transaction begins. This is recovery evidence for the bounded file and
Cargo operation; it is not an automatic application, database or deployment
migration.
The command does not install the CLI globally, rewrite Axum/SQLx/Tokio imports, run database migrations, modify secrets or authorization, validate live providers, or replace the project’s test suite. Follow the assisted upgrade tutorial and the v13 migration guide.
Update notice and installing a newer CLI
The CLI never downloads, verifies or replaces its own binary. Install a newer one with Cargo, using its exact version:
cargo install cargo-rullst --version =VERSION --locked
At most once a day, in an interactive terminal, cargo rullst tells you when a
newer release of its major is on crates.io: a newer stable release, or for a
prerelease CLI also a newer prerelease. The notice prints that exact command
(A newer cargo-rullst is available: cargo install cargo-rullst --version =X --locked) on the home screen, or on standard error after a command finishes.
It never delays or fails a command:
- the crates.io catalog is fetched in a background thread at most once a day
(one bounded HTTPS request, 4 seconds in total, no redirects) and kept in
the private user cache (
$XDG_CACHE_HOME/rullst-update-v1,$HOME/.cache/rullst-update-v1or%LOCALAPPDATA%\rullst-update-v1; unsafe owners, permissions or links make it unusable); - the notice itself reads only that cache, and the day of the last notice and
of the last request are small stamps in
rullst-ui-v1/in the same cache base; RULLST_DISABLE_UPDATE_CHECK=1,CARGO_NET_OFFLINE=true,CI, and any standard stream that is not a terminal silence it completely: no request, no file and no output.
Removed in 13.0.0-alpha.4: the self-updater (update check,
update verify, update stage and update install review|apply|recover)
and the CLI half of update guided (--scope, --root, --allow-major,
--prerelease). The native CLI executables and their manifests stay
attached to each GitHub release and can be checked by hand as described in
Verifying a release.
cargo rullst update guided (since 12.1.0)
cargo rullst update guided --to VERSION --project ./my-app
cargo rullst update guided --to VERSION --project ./my-app --offline
This interactive entry point composes the isolated project commands described
below: prepare, verify, review and apply. --to must be in this CLI’s major,
because the running CLI carries only its own major’s migration rules; to move
to another major, install that CLI first and run its guided flow. Each complete
review appears before its own default-no confirmation. The flow reuses the
exact version, directories and review digest without shell commands or manual
copying. It reports elapsed milliseconds per executed stage, excluding time
spent answering prompts. Declining stops before the next operation; completed
steps and their recovery records remain available.
Project verification asks whether Cargo may use the network and requires
explicit consent to execute trusted build scripts, macros and tests.
--offline keeps it offline. Feature selection and command deadlines use the
same --all-features, --features, --no-default-features and
--timeout-seconds options as project verification. PATH, databases and
deployments are not changed.
Piped input/output rejects before I/O; use the explicit commands with JSON and review digests for automation. This flow does not bypass ownership or recovery checks.
cargo rullst update project prepare (since 12.1.0)
Prepare dependency edits in a private source copy for review:
cargo rullst update project prepare --project ./my-app --to VERSION --json
The project must be a Git working directory containing Cargo.toml. The default
target is this CLI’s exact version, within its major train. Tracked and
non-ignored untracked files are copied with their current contents, including
uncommitted edits and tracked deletions. The root Cargo.lock is retained even
when ignored by an older generator. Other ignored files are omitted; tracked
secrets are still tracked inputs. The original files and Git index are not
edited. Limits are 100,000 entries, 64 MiB/file and 512 MiB/source snapshot;
the two copies can consume about 1 GiB before any build.
The command rejects linked/special inputs, unsupported paths, unknown migration
origins, ambiguous/unversioned managed dependencies and version/lockfile
downgrades. It accepts source majors 12 and 13 and records the
rullst-upgrade-rules-v4 source findings in the plan; verification, review and
application refuse a preparation with must-change findings (fix them in the
original project and prepare again), while review findings stay in the plan.
Older applications first upgrade to v12 with the v12 CLI. The
exact-version editor preserves TOML
comments. Offline, locked, dependency-free Cargo metadata enumerates workspace
members inside the copy. Rustup auto-installation is disabled using its
documented environment setting;
Git and Cargo must already be installed on absolute trusted PATH entries.
Every Cargo/rustc invocation in a project copy (preparation, verification,
review and apply) runs with RUSTUP_TOOLCHAIN pinned to the caller’s
toolchain: an inherited RUSTUP_TOOLCHAIN (rustup sets it for cargo rullst),
otherwise rustup’s configured default. A rust-toolchain/rust-toolchain.toml
in the project, including a path toolchain, is therefore not honored. Run the
CLI from outside an untrusted project: invoking cargo rullst inside it lets
rustup select that project’s toolchain for the CLI process itself.
The result points to before/, candidate/ and preparation.json inside the
private update cache. JSON uses rullst.project-preparation-result.v1, with a
rullst.project-preparation.v1 record of original file hashes and absences.
Compare the two trees and inspect the plan. Failed preparation removes its own
new staging directory. Successful preparations remain for review and can be
deleted by the caller after use.
Preparation executes no builds, procedural macros or tests, does not resolve a
new candidate lockfile, and authorizes neither execution nor application. The
copy is not a sandbox. Candidate verification, review, application and
recovery are described below. The existing upgrade command remains separate.
cargo rullst update project verify (since 12.1.0)
Review the commands and then explicitly authorize trusted project execution:
cargo rullst update project verify --prepared PATH --dry-run --all-features
cargo rullst update project verify --prepared PATH --allow-project-code --all-features --json
PATH is the private directory returned by preparation. The command validates
its records, before/candidate copies and current original files under an
exclusive operation lock. Unknown/stale inputs and unresolved migration findings
fail before project code runs. Fix findings in the original and prepare again.
Builds and tests use another fresh private copy, preserving the reviewed copy.
The sequence probes rustc --version --verbose and cargo --version, resolves
Cargo.lock with cargo update --workspace, checks all workspace targets, and
runs workspace tests with the resolved lockfile. That resolution keeps every
existing lock entry the edited manifests still accept and resolves only what
the dependency edits require; it does not upgrade unrelated pins (a missing
lockfile is resolved in full). Every managed Rullst package must resolve to the
exact target.
Default features apply unless --all-features, --features names or
--no-default-features selects another policy. This verifies that policy only;
application-specific service/browser/deployment tests remain separate.
Cargo is offline by default. --allow-network permits dependency retrieval but
cannot override CARGO_NET_OFFLINE=true. Rustup does not install missing
toolchains, and the project’s toolchain file is ignored as described above; set
RUSTUP_TOOLCHAIN explicitly to verify with the project’s pinned channel. --timeout-seconds bounds each command (default 900, range 1–3,600);
stdout/stderr are each capped at 8 MiB. Private logs retain failed Cargo output;
failure, cancellation or timeout never records acceptance. Builds/tests inherit
the caller’s environment and can affect external files, databases or services:
this copy and process cleanup are not a sandbox. Use trusted projects only.
Success emits rullst.project-verification.v1 and a private verification.json
with commands, log hashes and final file digests. Its verified candidate has the
resolved lockfile; original files are not edited. Source or baseline changes
during verification and unexpected candidate writes are rejected. Review the
complete diff and logs; compiler overrides/wrappers require separate review.
The report is not a reusable apply token and grants no application/deployment
authority. Final native application/recovery acceptance remains open.
Retained source copies can consume up to about 2 GiB, plus build outputs/logs.
cargo rullst update project review (since 12.1.0)
cargo rullst update project review --verified PATH --json
Use verified_directory from the verification result. Review validates the
stored command policy, successful statuses, bounded logs, original/prepared
files and verified candidate again under operation locks. Changed evidence is
rejected. Git produces the full dependency diff with external helpers, text
conversion and paging disabled; output is bounded to 8 MiB. No build/test runs
and original files remain untouched.
JSON (rullst.project-review-report.v1) contains the review record
(rullst.project-review.v1) with before/after file hashes, the full diff
and review_sha256 binding its evidence and contents. The digest is not
authorization to apply changes; review does not invoke the legacy in-place
upgrade command. The digest also binds the source access policies.
cargo rullst update project apply|recover (since 12.1.0)
cargo rullst update project apply --verified PATH --approved-review SHA256 --json
cargo rullst update project recover --verified PATH --approved-review SHA256 --json
After reviewing the complete diff and logs, supply that exact review_sha256.
Stop editors/builds and other writers first. The CLI revalidates all evidence,
locks the canonical source within the configured private cache, stages every
replacement and persists an intent before changing any original. Only reviewed
workspace manifests and the root lockfile can change. Original hardlink aliases
are not truncated, and a newly created lockfile cannot overwrite a competing file.
Recovery checks every target before restoring any file. A changed file must match
this operation’s before or after state and access policy; later conflicting edits
are refused. Unrelated files remain untouched. A root lockfile originally absent
is removed only if it still matches the recorded created file. Repeating recovery
is supported. Keep both private source copies and application.json until finished.
Replacement is atomic per file, not for the whole workspace. Partial failures
retain recovery evidence and report progress. Unix mode/owner/group and Windows
owner/group/DACL/integrity label are bound to the review (1 MiB total policy budget). Unix extended ACLs/xattrs and special
mode bits (except the SELinux security.selinux label, which the replacement
staged in the same directory receives like any new file there), Windows read-only/special attributes, alternate streams, resource/central-access
policies and policies that cannot be
recreated exactly require manual handling. Other updater cache configurations and
filesystem aliases do not share the lock. Forced termination during staging may
leave sibling .rullst-update-stage-* files. Timestamp and Windows audit-policy preservation are not implemented;
files that require them need manual handling. Native and process-interruption/power-loss acceptance remains pending.
Recovery does not undo application-code effects, databases or deployments.
cargo rullst pkg <action> [name]
Adds or lists rullst-*/rullst_* crate dependencies in the local
Cargo.toml. It executes no package code, queries no registry and changes no
application routing; Cargo resolves the crate on the next build.
- Subcommands:
add <package_name>: Adds the dependency (e.g.,cargo rullst pkg add rullst-auth) toCargo.tomlwith this CLI’s version as its requirement; an existing entry is left unchanged. In a virtual workspace manifest it adds the entry to[workspace.dependencies], for members to use with{ workspace = true }.list: Scans and lists all activerullst-*community extensions installed in your project (the workspace dependencies of a virtual workspace manifest).
An unknown action, or add without a package name, fails with a non-zero exit
status.
cargo rullst add
cargo rullst add <capability> [--dry-run] (v13) enables a facade capability
in an existing project. Capabilities: mail, auth, ai, nexus and
studio.
It runs only at the root of a Rullst project (a Cargo.toml whose
[dependencies] declare rullst), asks nothing and:
- enables the capability’s
rullstfeature inCargo.toml. The edit keeps comments, formatting and the other features; a feature that already implies it (for examplemailerorauth-sqlite) counts as enabled; - appends the capability’s variables to
.env.exampleunder a# ── <Capability> (added by cargo rullst add …) ──heading, creating the file when it is missing. Values are placeholders, empty ormock_*(the offline mocks of AGENTS.md 3.5), never real secrets; a variable that is already assigned or commented out is not repeated..envis changed only when it lacks a variable the application needs to start in development, and then receives the same mock value and is reported; none of the current capabilities needs one, so.envstays as it is; - prints the wiring code to paste and where it goes. Generated projects have
no marked insertion points, so
addnever edits application source; - prints one to three next steps.
| Capability | rullst feature | .env.example | Next steps |
|---|---|---|---|
mail | mail | MAIL_FROM, # MAIL_DRIVER, # RESEND_API_KEY | make:mail Welcome, dev |
auth | auth | APP_KEY placeholder | auth, make:mfa |
ai | ai | OPENAI_API_KEY=mock_openai_key, commented Anthropic, Gemini and Ollama entries | make:chat-session, generate:ai-context |
nexus | nexus | empty NEXUS_ADMIN_USERNAME and NEXUS_ADMIN_PASSWORD (required by release builds unless Nexus uses the application’s sign-in, NexusAuthPolicy::application) | dev, then /nexus |
studio | studio | none | dev, then http://127.0.0.1:5555 |
Running it again changes nothing and reports that the capability is already
enabled. --dry-run prints the planned diff of every file and writes nothing.
Outside a project it fails with the shared error report (exit status 1); an
unknown capability is a usage error (exit status 2).
cargo rullst add mail --dry-run
cargo rullst add mail
🛠️ 2. Architecture Scaffolding (make:*)
Rullst generators write the files described under each command. Some commands
also register modules and refresh .llms.txt; this is command-specific, and a
failed best-effort context refresh does not roll back generated source.
make:controller, make:model, make:middleware, make:worker,
make:island, auth, make:billing, make:cors and make:jwt also refresh
the generate:diagram output diagram.md: a missing file is created, while a
diagram.md the generator did not write is kept and reported instead of
replaced. Review the diff and run cargo check after scaffolding.
cargo rullst make:resource <name>
Scaffolds the bounded starting files for a CRUD resource in one command: a
Model (src/models/<name>.rs), Migration
(src/migrations/m<timestamp>_create_<plural>.rs, for example
create_categories for Category), Controller
(src/controllers/<name>_controller.rs), and HTML view placeholders
(views/<name>/index.html and views/<name>/form.html). It does not infer
application fields, register routes, establish ownership/RBAC, or turn the
placeholder handlers into a complete authorized CRUD implementation. Like
make:model --migration, it keeps an existing model and does not add a second
create-table migration for it. Mount the
routes behind the canonical security baseline, render request-scoped CSRF
tokens in state-changing forms, complete validation/persistence, and run the
application’s authorization-negative tests.
- Arguments:
<name>(e.g.,Productorproduct). - Optional Flags:
--api: Scaffolds a headless JSON API resource controller instead of HTML views.
cargo rullst make:controller <name>
Generates src/controllers/<name>_controller.rs (posts and
PostsController both produce posts_controller.rs) with placeholder CRUD
handlers (index, show, store, update, delete). It registers the
module in src/controllers/mod.rs and adds pub mod controllers; to
src/main.rs when that file exists; it does not add application routes
automatically.
- Arguments:
<name>(e.g.,UsersControllerorusers). A name whose module or type would not be a non-keyword Rust identifier (for exampleBad.Name) is rejected before any file is edited. - Optional Flags:
--api: Instead of returning HTML Views via thehtml!macro, the generated methods will automatically extract/returnJson<T>. The command prints theroutes!lines (/api/<name>and/api/<name>/{id}) and theMachineEndpoint::bearerlines formachine_endpoints()insrc/main.rs: in staging and production JSON clients reach the write routes only as machine endpoints, or the browser CSRF check refuses them with 403 (see which security layer to use).
cargo rullst make:model <name>
Creates a model struct in src/models/ with the ORM annotations. SQLx projects
receive #[derive(Debug, Clone, Orm)], which also implements FromRow (since
13.0.0-alpha.4), and import only what it uses (use rullst::db::{Orm, RowSnapshot};), so the new file compiles without warnings;
Turso-primary projects receive
#[derive(rullst_orm::Orm)] #[orm(backend = "turso")] and an i64 primary
key. Backend detection reads the generated manifest and does not treat an
additive --turso integration as the primary ORM. Like make:resource, it
rejects a name whose module or type would not be a non-keyword Rust identifier
(for example Match, which would declare pub mod match;) before writing.
- Arguments:
<name>(e.g.,BlogPost). - Optional Flags:
--migrationor-m: Simultaneously generates a reversible migration with the correctly pluralized table name. An existing model file is kept, and the migration is skipped when the model already existed or a*_create_<table>.rs/*_create_<table>_table.rsmigration exists, since a second create migration would drop the live table on rollback.
cargo rullst make:chat-session
Adds application-owned conversational memory for the project’s primary ORM.
It generates and registers ChatSession and ChatMessage, a reversible
migration, and StatefulChat. SQLx and the bounded Turso-primary profile receive
backend-specific code; the command also enables the orm and ai umbrella
features if necessary.
cargo rullst make:chat-session
cargo rullst db:migrate
Save the generated ChatSession before constructing StatefulChat. Each
service instance serializes concurrent sends, restores at most the newest 100
messages in chronological order, persists the user message before provider
dispatch and persists the assistant response only after success. Database and
provider failures are returned as StatefulChatError; they are never silently
discarded. Multi-process ordering, tenant authorization, retention and deletion
remain application responsibilities. The command refuses to overwrite an
existing chat scaffold.
cargo rullst make:middleware <name>
Generates a standard Axum/Rullst Middleware struct in src/middlewares/. Perfect for injecting headers, checking authentication, rate limiting, or logging.
A name whose module and function would not be a non-keyword Rust identifier is
rejected before any file is edited.
cargo rullst make:island <name>
Creates a frontend interactive “Islands Architecture” component (similar to Fresh or Astro) in src/islands/. It generates the Rust infrastructure that, during build, will be transparently compiled to WebAssembly to run in the browser.
cargo rullst make:worker <name>
Creates an asynchronous background worker in src/workers/ against the queue
backends currently implemented in Core (memory, SQLite, and optional Redis).
RabbitMQ is not generated by this command. The generated handler logs only the
job name and its number of payload fields, never the payload itself, because
jobs often carry addresses, tokens or other personal data.
cargo rullst make:migration <name>
Generates a timestamped reversible Rust migration for the project’s primary
backend. SQLx projects use the schema DSL; Turso-primary projects use
TursoMigration and parameterized TursoStatement values, and regenerate a
fallible typed migration registry. The name is lowercased with - mapped to
_ and otherwise kept intact (modify_users_email produces
m<timestamp>_modify_users_email); names with other characters, such as
add_index.v2, are rejected. Regenerating src/migrations/mod.rs fails, naming
the file, when an m*.rs file there is not a valid Rust module name.
cargo rullst make:migration:auto
Takes no arguments and compares the #[derive(Orm)] models under src/
with the SQLite database named by DATABASE_URL in .env. Without a
.env, without a DATABASE_URL (a --no-database project), with a
non-SQLite URL or in a Turso-primary project there is nothing to compare: it
prints a “No migration generated” report naming the reason and exits with
status 1. When the database already matches the models it writes nothing and
exits with status 0; only a written migration is followed by the
cargo rullst db:migrate next step. It ignores framework tables
(migrations and rullst_*) and writes a migration only for
additive changes (new tables or columns), with drops of model-less tables or
columns included as commented-out code for review. When only such destructive
differences remain, it lists them and writes no migration. Column types follow
the field’s Rust type: i8/i16/i32 use integer, i64 big_integer,
f32/f64 float, bool boolean, and String, SecretString, chrono
date/time types and Json use string. A new table declares non-Option
fields NOT NULL. A required field added to an existing table is declared
NOT NULL DEFAULT 0, 0.0 or '' (a commented backfill to review); a
required date, encrypted or JSON field added to an existing table has no
neutral value and is refused. Any other type (for example Vec<u8>, Uuid or
an application enum) is refused, naming the field, and nothing is written; add
such columns with make:migration.
cargo rullst make:billing
Scaffolds a SaaS billing starting point with subscription models, authenticated billing routes, and signed-webhook integration points. Provider credentials, tenant policy, and deployment behavior still require application configuration.
- Optional Flags:
--model <MODEL>(defaultUser): The billable model; the subscription and customer tables reference it through a<model>_idcolumn (Teamgivesteam_id).
Empty or mock_* credentials select a local development fixture, which is
refused in production. Other credentials select the real Stripe profile
(BILLING_PROVIDER=stripe, the only accepted value), which creates provider
customers, checkout sessions and portal sessions, persists the
owner/customer/attempt bindings and processes signed webhooks atomically. It
requires BILLING_ACCOUNT_ID=acct_..., an sk_test_/rk_test_ or
sk_live_/rk_live_ BILLING_API_KEY, a strong BILLING_WEBHOOK_SECRET and
an HTTPS BILLING_REDIRECT_URL; live keys additionally require
BILLING_LIVE_ACKNOWLEDGEMENT=I_UNDERSTAND_REAL_CHARGES. To bill through
another gateway, implement the Capital provider traits in application code; see
Writing your own payment provider.
Mixed mock/real credentials, incomplete configuration and any other
BILLING_PROVIDER value return HTTP 503. The generated BILLING.md lists the
permissions, webhook events, recovery procedures and remaining limits.
Hosted checkout also requires the submitting page’s CSP to allow its exact
reviewed destination in form-action. The SaaS starter selects Stripe and
generates form-action 'self' https://checkout.stripe.com while retaining the
rest of Core’s strict policy. make:billing prints this requirement and leaves
your existing policy for review. A custom provider requires its exact checkout
origin; do not allow https: or wildcard domains. Keep a single form-action directive in security.csp and
review proxy/CDN policies too: another restrictive CSP still applies.
Independently validate each returned URL (HTTPS, exact host/port, no embedded credentials) and the session’s owner, product and test/live mode before a 303. Test the form submission in a real browser: a successful HTTP redirect alone does not prove that CSP permits navigation. Before enabling live billing, resolve an owner’s persisted open attempt before charging the new-session quota; resume only a retrieved, fully bound open session. Expired, completed and uncertain outcomes require separate handling and reconciliation.
cargo rullst make:mail <Name>
Scaffolds a registered transactional mailable. --welcome, --reset, --otp
and --invoice select the bounded built-in variants (at most one); without a
flag the command generates a custom message type. It enables the umbrella
mailer feature, uses the rullst::mail facade, escapes dynamic HTML and
refuses invalid identifiers, path traversal or an existing target. Generated
mailables set no from, so the facade uses the MAIL_FROM (or [mail] from)
default sender, which new projects list in .env.example next to a
MAIL_DRIVER hint; staging and production must select a driver before
sending. Delivery credentials, URL
semantics, tenant policy and provider operation remain application
responsibilities.
cargo rullst make:mail-invoice [Name]
Generates PaymentReceiptEmail by default and enables mailer. The result is
a bounded commercial payment receipt (international_receipt, optional
with_document_url) that states it is not a tax authorization; it runs the
mail pre-flight before returning the message. A custom valid struct name may be
supplied positionally.
cargo rullst make:mail-dunning [Name]
Generates PaymentDunningEmail by default with explicit gentle D+1,
action-required D+3, and service-status D+7 stages. The application remains
responsible for calculating the due state, scheduling delivery, enforcing its
disclosed billing policy, and reconciling payment. The generated build path
runs the mandatory pre-flight and rejects dangerous links.
cargo rullst make:age-gate (v13 prerelease preview)
Adds an explicit first-party declaration before each visit to the recognized
SaaS starter’s /dashboard. Generation requires a server-owned policy version,
threshold and single-tenant deployment reference; it never chooses a universal
legal minimum age. The existing authentication and CSRF layers protect both
GET and POST. A successful answer remains declared, and authorizes only that
dashboard rendering after durable one-use consumption.
cargo rullst make:age-gate \
--privacy-source /path/to/Rullst/rullst-privacy \
--minimum-age 18 \
--policy-version dashboard-v1 \
--tenant-ref application-tenant-ref \
--replay-store sqlite
The threshold above is an example for an application-assessed low-assurance
policy. Select postgres explicitly for a shared database across hosts.
Configure the required private key and replay database as described by the
generated AGE_GATE.md; missing state or configuration denies access. The
candidate defaults to the registry version matching this CLI. The source
override shown above is required for development before that version is published,
unless an explicit archive-only patch is configured. Generation refuses unknown
privacy dependencies or unrecognized authentication/route shapes before writing.
It composes with make:privacy when both use the same registry/local source;
both consumers’ explicit dependency features are preserved.
Review the generated diff before deployment. It does not install facial models,
verified guardianship, reusable age flags or global compliance.
It targets the recognized SaaS starter (--blueprint saas, the default) and
requires --tenant-ref. v13 removed the LMS target together with the complete
Academy scaffold. The tenant is compiled into the generated configuration; no
query parameter, header or form field is read to select it.
cargo rullst make:privacy (v13 prerelease preview)
Adds authenticated preferences at /privacy, an optional personalized greeting
at /privacy/personalization, and a private own-account JSON download at
/privacy/export. The concrete export projects only the authenticated account’s
ID, name and email; it does not complete broader queued privacy requests or
export subscription, learning, guardian, backup or processor records.
cargo rullst make:privacy \
--privacy-source /path/to/Rullst/rullst-privacy \
--purpose-version greeting-v1 \
--validity-seconds 86400 \
--tenant-ref application-tenant-ref
The version and lifetime are explicit application choices; the engineering cap
of 365 days is not a legal retention rule. It targets the recognized SaaS
starter and requires --tenant-ref; v13 removed the LMS target. The tenant is
compiled into the generated configuration. The privacy routes take no query
string: a request carrying one, or an x-school-id header, is refused rather
than allowed to select another tenant or account.
The generated consumer composes with make:age-gate in either installation order.
Omitting --privacy-source selects this CLI’s matching registry version; before
publication use the explicit local override shown above or a reviewed archive patch.
Unknown authentication, dependencies, routes or existing output files require
manual integration rather than overwriting application code.
Follow generated PRIVACY.md: provision an independent random
RULLST_PRIVACY_FORM_KEY_HEX and initialize a new private local consent file with
cargo run --bin privacy-init, with RULLST_PRIVACY_DATABASE exported into that
process environment. Ordinary opening never creates or repairs missing state.
The generated 10,000-record SQLite store requires one trusted shared local file;
it does not provide multi-host replication. Preferences and personalization deny
unavailable state, while the authenticated export remains independent of that
store and form key.
Choices are initially unselected. Refusal and withdrawal produce a generic greeting; an earlier positive form cannot undo a completed withdrawal. Forms bind the displayed notice, revision, account, tenant and session and expire after five minutes. The server checks current permission before the optional name query. Add visible application navigation and review the documented backup, retention and broader rights obligations before deployment. This bounded consumer does not establish worldwide legal compliance or verify age.
cargo rullst make:jwt
Injects a pre-configured boilerplate Middleware into your project for strict JWT Authentication (verifying Bearer tokens in the Authorization header).
cargo rullst make:cors
Generates and configures full CORS (Cross-Origin Resource Sharing) options in your project with recommended security defaults (blocking unused methods, restricting origins).
Projects generated by older CLI versions retain the middleware that was copied into their source tree and must be reviewed manually. Follow the CORS scaffold security advisory to detect origin reflection/wildcards and migrate to the current fail-closed allowlist.
cargo rullst make:pwa (13.0.0-alpha.3)
Makes an HTML application an installable Progressive Web App and wires it into the starter’s own router and page layouts:
cargo rullst make:pwa
cargo rullst make:pwa --name "Field Notes" --short-name Notes \
--theme-color '#0f172a' --background-color '#020617' --lang pt-BR --start-url /
| Flag | Default | Meaning |
|---|---|---|
--name | [package].name in words (field-notes → Field Notes) | 1-64 letters, digits, spaces, ., -, _ |
--short-name | the name when it has at most 12 characters, else its first word cut to 12 | label under the home-screen icon |
--theme-color, --background-color | #0f172a, #020617 | #RRGGBB |
--lang | en | language tag such as en or pt-BR |
--start-url | / | a path on this site that the installed app opens |
--android | off | also write android/twa-manifest.json (Bubblewrap) and PWA-ANDROID.md; needs --host and --package-id |
--host | none | with --android: the site’s HTTPS host, such as notes.acme.dev |
--package-id | none | with --android: the Android application ID you own (com.example.* is refused) |
It writes static/manifest.webmanifest (id and scope /, display
standalone, icons 192 and 512 plus a maskable 512), four placeholder PNG
icons in static/icons/ (the Rullst logo; replace them), static/pwa.js,
src/pwa.rs, the worker in src/pwa/sw.js and PWA.md. src/pwa.rs serves
/sw.js (text/javascript, Cache-Control: no-cache, scope /) and
/offline (no-cache, so the worker may store it), and its head() returns
the manifest link, theme-color, the apple-touch icon and the registration
script. The existing source is edited in three recognized places:
- the router:
.merge_axum(crate::pwa::router().into_axum())follows the one.merge_axum(rullst::health::health_router())of the crate root (a merge in areturnexpression, such as the fallback router of an application generated from the former ERP starter, does not count); - every page layout in
src/:{crate::pwa::head()}before the</head>of anhtml!document, the same tags before the</head>of a document written in a string, andrender_page_with_lang(...)calls becomerender_page_with_head(..., crate::pwa::head(), content); - the crate root (
src/lib.rs, elsesrc/main.rs) declaresmod pwa.
The registration script is a same-origin file, so the default
Content-Security-Policy (script-src 'self' 'nonce-…', worker-src 'self',
the manifest under default-src 'self') allows it without a nonce. The worker
precaches /offline and the PWA’s static files, goes to the network first for
navigations and unversioned static files, serves content-hashed static files
from the cache first, and never stores a non-GET request, another origin or a
response marked no-store or private; Rullst answers no-store by default,
so authenticated pages stay off the device. A new worker waits until the user
chooses Reload in the page’s update prompt (SKIP_WAITING). The cache is
named after [package].version.
The generated router also serves /.well-known/assetlinks.json (Digital
Asset Links for the Google Play package) once RULLST_ANDROID_ASSET_LINKS or
[[android.asset_links]] in Rullst.toml names the app and its signing
certificate fingerprints; see rullst::pwa::AssetLinks.
The whole plan is checked before any write. Running the command again changes
nothing; a generated file that now differs (edited, or another --name) stops
the run without writing. --android can follow a plain run with the same
flags and adds only the Android files. The JSON API starter, a project without an HTML
layout, and a router without the health merge are refused; wire such a project
by hand as the PWA guide shows.
cargo rullst make:iot <DeviceName>
Maturity: Experimental · tiers
Scaffolds and registers a telemetry-only IoT module in src/iot/ using the
public rullst::iot::SensorTelemetry facade, and enables the iot feature in
the application manifest. Unsafe identifiers/path traversal and existing target
files are rejected. It does not install an MQTT/CoAP transport, HAL, firmware,
or claim broker connectivity.
cargo rullst make:k8s
Scaffolds cloud-native Kubernetes manifest files in the k8s/ directory (deployment.yaml, service.yaml, configmap.yaml, hpa.yaml, ingress.yaml, and all-in-one.yaml) pre-configured with liveness (/health) and readiness (/ready) HTTP probes.
The command fails before writing anything when any of these manifests already
exists, and it does not write through a symlinked k8s/ directory or file; move
customized manifests aside to regenerate the templates.
Object names, the image reference and the ingress host use the [package]
name as a lowercase RFC 1123 label: characters other than letters and digits
become - (my_app becomes my-app), and the label is capped at 55
characters so suffixed names such as <name>-service stay valid.
cargo rullst make:scalar
Scaffolds a Scalar API Documentation controller at
src/controllers/docs_controller.rs. The interactive view loads a pinned CDN
asset; its local fallback is status-only and final CSP/network policy belongs to
the application.
cargo rullst make:live <ComponentName>
Scaffolds a LiveView-style server component at src/live/<name>.rs using a
WebSocket and HTMX out-of-band swaps. Application JavaScript may be unnecessary,
but HTMX remains client-side JavaScript and the generated transport requires
origin, reconnect, and backpressure review. It must run in a Rullst project
root, and a name whose module or type would not be a non-keyword Rust
identifier (for example ../notes, self or Bad.Name) is rejected before
anything is written.
cargo rullst deploy
Writes the files that run the application on any Linux VM with Docker or Podman, each only when it is absent (existing files are kept, and nothing is written through a link):
Dockerfileand.dockerignore: the same multi-stage build asdockerize, with its dependency-cache (planner) stage and a distroless, non-root runtime image. When the[database] urlinRullst.toml(or, without one,DATABASE_URLin.env.example) is SQLite, the image setsDATABASE_URL=sqlite:///app/data/rullst.db?mode=rwc, so the database lives in theapp_datavolume; an existing Dockerfile without it gets a warning;compose.yaml: the app and a Caddy reverse proxy with automatic HTTPS forDOMAIN. Both containers have a read-only root filesystem, every capability dropped (Caddy keeps onlyNET_BIND_SERVICEfor ports 80 and 443) andno-new-privileges; the app reads.env.productionand keeps its data in theapp_datavolume; the app’s/healthendpoint is checked from the Caddy container, because the app image has no shell or HTTP client. It works withdocker composeandpodman compose;Caddyfile: proxies to the app, checks its/healthevery 5 seconds and stops routing to it while that fails, and holds each request for up to 15 seconds while the app is down (a restart or an update) instead of answering503at once.
Caddy is pinned to 172.31.250.10 on a 172.31.250.0/24 compose network,
and the command adds that address to [security] trusted_proxies in
Rullst.toml (before a missing Dockerfile is scaffolded, so the image
copies it), so rate limits see each client instead of Caddy. An existing
trusted_proxies list is left unchanged with a warning. The command prints
the next steps: create .env.production, point DNS at the VM, run
DOMAIN=example.com docker compose up -d --build (or
podman compose up -d --build) and run db:migrate once per release.
Nothing is uploaded, started or deployed. A Cargo.toml package name that is
not a valid Cargo name is refused before anything is written, because the
Dockerfile names the binary after it.
Step by step: Deploy to a Linux VM, and the guides for Hetzner Cloud, DigitalOcean, Oracle Cloud and Google Cloud.
Removed in 13.0.0-alpha.4: the Fly.io, Railway and Render targets
(--platform fly|railway|render now fails before writing anything;
--platform vps, the former name of this path, is still accepted). Provider
files you already have keep working with the provider’s own CLI. Nix,
Buildah, Podman Quadlet and Kubernetes are described in
Advanced deployment. Credentials, migrations,
availability, DNS and rollback remain operator responsibilities.
cargo rullst auth
Creates an authentication starting point in your codebase, including:
- User model and migration with asynchronous Argon2 password hashing and a
session_epochcolumn. - Auth Controllers (Login, Registration, Logout, and
logout_everywhere, which increments the account’s session epoch to end its session on every device). - Encrypted-session middleware that inserts the signed-in user’s id as
Extension<i32>and refuses a cookie whose session epoch is no longer the account’s. - A password controller (
src/controllers/password_controller.rs), a reset token model (src/models/password_reset.rs) and apassword_reset_tokensmigration: change password (signed in; checks the current password, keeps only the asking browser signed in) and reset by email (a 30-minute single-use token stored as a SHA-256 digest, one live token per account, the same answer for unknown emails; a reset signs out every device and redirects to/login?reset=done). Reset links useRULLST_PUBLIC_ORIGIN, never the request’sHostheader. Account emails are written in the request’s language (therullst::i18nlocale, elseAccept-Language) and name the productAPP_NAMEor the editableAPPLICATION_NAMEconstant. - HTML Views for Login, Signup, Forgot password, Reset password and Change password.
It targets the SQLx ORM: Turso-primary projects are rejected. The command
enables the orm, auth and mail umbrella features, registers the generated
controllers, middlewares, models and pages modules in src/lib.rs (or
src/main.rs), and refreshes the migration registry.
In a Blank or API database starter (SQLite, PostgreSQL, MySQL or MariaDB) the
unchanged starter migration m20260601000000_create_users_table.rs already
creates users with id, name and timestamps. The command keeps it and
writes m<timestamp>_add_account_columns_to_users_table.rs instead of a
second users table: in one migration transaction (MySQL/MariaDB commit each
DDL statement on its own) it adds email (VARCHAR(255) NOT NULL DEFAULT '',
as SQLite adds a NOT NULL column only with a default), password_hash,
oauth_provider, oauth_id, is_admin and session_epoch, gives every
existing row the unique placeholder email user-<id>@no-email.invalid (a
reserved domain that never receives mail) and no password, and creates the
unique index users_email_unique. It therefore works before and after the
starter’s first db:migrate, and no row is lost; its rollback drops only
those columns and the index. The starter’s two-field User struct in
src/main.rs (or src/lib.rs) is replaced, while unchanged, by
pub use crate::models::user::User;.
When src/models/user.rs, src/models/password_reset.rs,
src/controllers/auth_controller.rs, src/controllers/password_controller.rs,
src/middlewares/auth_middleware.rs and src/pages/auth.rs all exist, as in
the SaaS starter or after an earlier run, the command reports that
authentication is already scaffolded, writes nothing and exits with status 0.
It fails before writing anything when only some of those files exist, when a
*_create_password_reset_tokens_table.rs or
*_add_account_columns_to_users_table.rs migration exists, or when a
*_create_users.rs/*_create_users_table.rs migration other than the
unchanged Blank/API starter one creates the users table (its columns are
unknown: add them with make:migration). It also refuses a project without a SQL migration runner (pub mod migrations; in src/main.rs or src/lib.rs plus a
rullst::artisan!(crate::migrations::get_migrations()) call, which database
starters include), such as a --no-database starter, because nothing would
compile or apply the users migration. Generated code names the ORM through the
rullst::orm facade, so no direct rullst-orm dependency is needed. Mounting
routes (including POST /logout/everywhere and /password/change behind
auth_middleware, and credential_rate_limit on the password submissions),
the security baseline and the mail settings (MAIL_DRIVER, MAIL_FROM,
RULLST_PUBLIC_ORIGIN) remain application work, as does calling
User::revoke_sessions when you deactivate an account. The SaaS starter and
the LMS example (examples/lms) ship the same password flows already mounted.
cargo rullst make:mfa
Scaffolds a server-side RFC 6238 TOTP second factor: src/controllers/mfa.rs
and a reversible user_mfa_factors migration (one factor per account). The
secret is generated and stored on the server and bound to the signed-in
account: mfa_setup, mfa_confirm and mfa_verify take the user id from the
Extension<i32> that the cargo rullst auth middleware inserts, and a client
never submits a secret. Verification uses verify_totp_step_after with an
atomic conditional update of last_accepted_step, so each code is accepted at
most once. Setup returns the secret and otpauth:// URI once with
Cache-Control: no-store; enrollment stays pending until mfa_confirm
accepts a current code.
The command targets the SQLx ORM (Turso-primary projects and projects without
a migration runner are rejected, as for cargo rullst auth), enables
the orm and security umbrella features, registers the module, refreshes the
migration registry and refuses to overwrite an existing src/controllers/mfa.rs
or *_create_user_mfa_factors_table.rs migration. Mount the handlers as POST
routes behind the authentication middleware, CSRF protection and rate limiting,
and require a recent password check before enrollment. To gate login, keep the
session pending until verify_second_factor succeeds; the generated auth
controller issues a full session after the password. Secrets are stored
unencrypted in the database, so protect that table and its backups like
credentials. Recovery codes and factor reset remain application work.
cargo rullst make:oauth <provider> (v13 prerelease preview)
Adds sign-in with Google, GitHub, Microsoft or Apple to the account model of
cargo rullst auth or of the SaaS starter (and of applications generated from
the former LMS starter, such as examples/lms). Run it once per provider;
other providers are refused until they have a generator and tests.
cargo rullst make:oauth google # or github, microsoft, apple
cargo rullst db:migrate # once, after the first provider
The first provider writes src/controllers/oauth/mod.rs (shared flow, account
linking and sign-in), src/models/oauth_identity.rs and an
oauth_identities migration (provider and subject unique, the user id with
ON DELETE CASCADE, created at). Every provider writes
src/controllers/oauth/<provider>.rs (settings and the /auth/<provider>
and /auth/<provider>/callback handlers), registers it in the shared module
and documents its settings in .env.example; a second provider adds no
migration and shares the table, the flow and the linking policy. The command
enables the oauth umbrella feature and the challenge store of the project’s
database (oauth-sqlite, oauth-postgres or oauth-mysql) and adds an
application feature oauth-mock = ["rullst/oauth-mock"]. In the SaaS starter
and LMS-starter applications it also mounts the routes after the password login, with the same
credential_rate_limit, calls controllers::oauth::<provider>::initialize()?
at the start of router() and adds a “Sign in with …” link to the login page;
for a cargo rullst auth project, whose router is the application’s, it
prints the routes to mount.
The flow keeps the OAuth state, PKCE verifier and OIDC nonce in
rullst_connect_login_challenges (created on first use) and only an opaque id
in the rullst_oauth_challenge cookie (ten minutes, HttpOnly,
SameSite=Lax, Path=/auth/<provider>, Secure in staging and production);
see Tutorial 42.
The callback consumes the challenge once, checks the state and the PKCE
verifier and, for OpenID Connect providers, the ID token and its nonce, then:
- an identity already in
oauth_identities(provider and subject) signs in to its account; - otherwise the provider must have verified the email; an unverified email is refused;
- a verified email that belongs to no account creates one without a password, linked to the identity;
- a verified email that already belongs to an account is refused by default
(
EXISTING_ACCOUNT_POLICY = ExistingAccountPolicy::Refuseinsrc/controllers/oauth/mod.rs), because registration does not prove that whoever registered the address owns it.LinkAndResetPasswordlinks it instead, removes the account’s password and signs out every device in the same transaction. The policy applies to every provider, including a second provider of an account that signed in with the first.
What counts as the subject and as a verified email differs per provider:
| Provider | Subject | Verified email |
|---|---|---|
google (OpenID Connect) | ID token sub | email with email_verified: true |
github (OAuth 2.0) | numeric account id of GET /user, never the login name | the primary address of GET /user/emails marked verified; the default user:email scope grants it |
microsoft (OpenID Connect) | <tid>:<oid> (directory and object IDs) | email only with the optional claim xms_edov: true, which the app registration must request with email |
apple (OpenID Connect, form POST) | ID token sub | email with email_verified; possibly a private relay address |
Sign-in issues the password login’s cookie, make_login_cookie_with_epoch with
the account’s current session epoch; an account whose session epoch the
password login refuses is refused the same way. The page to return to comes
from ?next= on /auth/<provider>, and only a same-site absolute path is
kept.
Settings (process environment, then .env); setting none of a provider’s
credentials turns it off and hides its link, a partial set stops startup:
- Google:
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET. - GitHub:
GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRETof an OAuth App. - Microsoft:
MICROSOFT_TENANT(required with the credentials:common,organizations,consumersor a directory ID GUID; there is no default, because it decides who can create accounts),MICROSOFT_CLIENT_ID,MICROSOFT_CLIENT_SECRET. - Apple:
APPLE_CLIENT_ID(the Services ID),APPLE_TEAM_ID,APPLE_KEY_IDandAPPLE_PRIVATE_KEY, the PEM text of the.p8key (newlines may be written as\n). Rullst Connect signs a five-minute ES256 client secret with it for each code exchange; a key that cannot sign stops startup. Keep it in the environment or a secret store: the command never writes it to a file. <PROVIDER>_REDIRECT_URLdefaults toRULLST_PUBLIC_ORIGINfollowed by/auth/<provider>/callback, and tohttp://localhost:3000/...in development; staging and production without either stop at startup.mock_*values select the offline provider, which signs in[email protected]without leaving the application; the command prints the exact settings, such asGITHUB_CLIENT_ID=mock_github GITHUB_CLIENT_SECRET=mock_github cargo run --features oauth-mock. Without that feature the application stops at startup with an error naming it, and staging and production refuse mock credentials.
Apple POSTs its callback from appleid.apple.com, so make:oauth apple
mounts POST /auth/apple/callback (plus a GET that only the offline mock
uses), makes the challenge cookie SameSite=None; Secure, and lists
/auth/apple/callback in [security] csrf_signed_webhook_paths of
Rullst.toml (creating the file when missing): instead of the CSRF token,
the handler requires the state of the login challenge that the browser’s
challenge cookie names. Its name arrives only on the first authorization, in
the unsigned user form field, and only names a new account. Register the
MAIL_FROM domain with Apple’s Private Email Relay service so account emails
reach relay addresses.
The command requires a strict single-database profile (strict-sqlite,
strict-postgres or strict-mysql, as cargo rullst new --database ...
selects), the User model with session_epoch, current_session_epoch and
find_by_email, the generated auth_controller helpers, src/pages/auth.rs
and a migration runner. It refuses Turso-primary projects, and it fails before
writing anything when any output exists or the provider is already installed.
It does not edit auth_controller.rs or user.rs.
🗄️ 3. Database and Migrations (db:*)
Every db:* command (including cargo run -- db:migrate and a deployed
binary’s db:migrate job) and studio select the database exactly like
Server: the process DATABASE_URL, then DATABASE_URL in ./.env (which
never overrides a variable already set in the process), then [database].url
in Rullst.toml, read with a TOML parser. A Server::with_db URL applies when
Server::run intercepts the command. Without a configured database, a db:*
command exits with status 1 instead of creating a SQLite file. Configuration
errors (reported without file content) and database initialization failures
also exit with status 1.
cargo rullst db:migrate
Runs cargo run -- db:migrate, which the application’s
rullst::artisan!(crate::migrations::get_migrations()) call intercepts. It
applies, in one new batch, every migration registered in
src/migrations/mod.rs whose name is not yet recorded in the ORM’s
migrations table, calling each migration’s up.
cargo rullst db:rollback
Reverts the last batch: it calls down for each migration recorded in the
latest batch, newest first, and removes their records.
cargo rullst db:status
Lists every registered migration as Applied or Pending according to the
migrations table.
cargo rullst db:seed
Runs the seeders (rullst::db::Seeder implementations) that main passes as
the second argument of rullst::artisan!(migrations, seeders), for example an
initial administrator or development data.
Turso-primary starters handle db:migrate, db:rollback and db:status in
their generated main.rs through TursoOrm instead of rullst::artisan!.
cargo rullst studio
Runs cargo run -- studio, which Core’s Artisan handler serves as a smaller
local compatibility UI (database, AI, telemetry, security, capital and span
pages) on 127.0.0.1:5555 until it stops; it is not the full rullst-studio
router that debug builds of the generated starters start on the same port
(see Rullst Studio). Treat it as a privileged
development tool; do not expose it publicly without an independently reviewed
authentication, authorization, and TLS boundary.
cargo rullst studio --remote [--port PORT] needs no project: it starts the
installed standalone rullst-studio binary
(cargo install rullst-studio --version =13.0.0-alpha.4 --features remote --locked;
Cargo installs a prerelease only when its version is named),
which serves Studio’s remote-projects mode on 127.0.0.1:5556 (or PORT)
and reads the production monitoring endpoint
of the projects you add. Without the binary it explains how to install it.
🧠 4. Analyzers and Code Generators (generate:*)
cargo rullst generate:openapi
Reads recognizable route and Rustdoc patterns and writes an OpenAPI 3.0 draft to
openapi.json in the project root.
Dynamic routes, custom extractors, and semantic constraints may require manual
edits; validate the result with an OpenAPI validator before publishing it.
cargo rullst generate:ts
Scans recognizable route declarations in src/main.rs and src/lib.rs and
emits rullst-client.ts with unchecked request/response placeholders. Axum
{name} and {*name} captures (and legacy :name segments) become method
arguments interpolated with encodeURIComponent (per segment for a wildcard).
State-changing requests (anything but GET, HEAD, OPTIONS and TRACE)
follow the CSRF middleware’s double-submit contract: the client echoes the
rullst_csrf cookie in the X-CSRF-Token header and sends cookies with
credentials: 'same-origin'. The cookie is set by a GET/HEAD response, so
load a page or issue a GET before the first state-changing call. Outside a
browser, or for a cross-origin baseUrl, pass
new RullstClient(baseUrl, { csrfToken: () => token, credentials: 'include' }).
Review the output before use; route scanning does not establish DTO shapes,
serialization or authorization.
cargo rullst generate:api (v13 candidate)
Consumes one explicit bounded OpenAPI 3.1 profile and generates Rust DTOs/codecs,
a typed TypeScript HTTP client and a canonical schema copy. Requires --schema
and --output; --check verifies freshness without writes. Unsupported shapes
fail before generation. See the profile and executable acceptance.
cargo rullst generate:diagram
Analyzes primary and foreign keys defined in your Models and exports a diagram.md file containing Mermaid.js code, visually generating an Entity-Relationship (ER) diagram.
The file starts with a generator marker comment. An existing diagram.md is
replaced only when it carries that marker (or is the single unmarked Mermaid
block earlier releases wrote); the command refuses a hand-written file or a
symlink, so move it aside to regenerate the diagram.
cargo rullst generate:models / cargo rullst make:models-from-db
Connects to an existing database and generates reviewable starter structs from
the tables and columns visible in SQLite or the current PostgreSQL/MySQL schema.
Table lookups are parameterized and SQL identifiers are allowlisted.
PostgreSQL and MySQL metadata columns are cast to text and aliased, so
PostgreSQL 12+ sql_identifier columns and MySQL 8 upper-case labels are read
portably; a missing or undecodable metadata column fails with an error rather
than a panic. Table module names are normalized, while collisions and database columns that would
require an unsupported ORM field remapping fail before the output directory is
written. Existing model files are never replaced: if any <table>.rs target
already exists, the command fails before writing anything. An existing
mod.rs keeps its content and receives only missing pub mod declarations.
The bounded type mapping falls back to String; review keys, relations, custom
types, schema selection and generated files before compiling them.
- Required Flags:
--driver:postgres,mysql, orsqlite.--url: The complete connection string.
- Optional Flags:
--output: Where to save the generated structs (Default:src/models).
cargo rullst generate:ai-context [--check]
The v13 candidate writes a bounded .llms.txt and .rullst/context-map.json
with dependency metadata, configuration key names and source paths. It creates
AGENTS.md only when absent and preserves existing project instructions.
--check detects missing, altered or stale inventory without writing files.
See project context for limits, exclusions and legacy migration.
cargo rullst ai (removed)
The terminal AI assistant of the earlier 13.0 prereleases (ai, ai connect,
ai status, ai disconnect, ai upgrade, ai fix and ai review) was
removed in 13.0.0-alpha.4. cargo rullst ai …, with any arguments, prints
what replaces it and exits with status 1; the command is hidden from --help,
completions, the command palette and the home screen.
Use your editor’s or terminal’s AI assistant instead, with the project’s
AGENTS.md and .llms.txt
(generate:ai-context writes and
checks them) and the framework’s published
llms.txt. The rullst-ai crate,
which applications use to call models, is unchanged. In place of
ai fix <error-id>, the development error page’s “Ask your AI assistant”
panel gives a prompt to copy into your assistant and the path of the recorded
error context. Credentials that
ai connect saved stay in ~/.config/rullst/credentials.toml
($XDG_CONFIG_HOME/rullst/, or %APPDATA%\rullst\ on Windows), and its git
checkpoints under refs/rullst/ai-checkpoints/, until you delete them. See
the migration guide.
cargo rullst eject [--force] [--output <path>]
Writes a reviewable Axum/Tokio entry-point template (src/ejected_main.rs). It
is not a translation of the project’s main.rs: it serves a placeholder route
from application_routes(), wraps it in the configured Rullst security
baseline (apply_security_baseline with Rullst.toml, as Server applies it)
and reaches Axum through rullst::web::axum, so it compiles without a direct
Axum dependency. Move the application’s routes and module declarations into it
before use. Static files, rate limiting, lifecycle probes, hot reload,
sessions, authentication and authorization are not included.
- Flags:
--force: Replacessrc/main.rswith the template after copying the original tosrc/main.rs.rullst-backup(an existing backup stops the command). Application routes are not carried over.--output <path>: Specifies a custom output path belowsrc/for the ejected file.
cargo rullst deploy:doctor (v13 candidate)
Read-only inspection of a local deployment configuration snapshot:
cargo rullst deploy:doctor --env-file .env.production --json
cargo rullst deploy:doctor --config Rullst.production.toml --process-env
Reuses Core environment/security validation, catches obvious key/configuration
mistakes and identifies application-policy reviews without echoing values.
--target <production|staging> (default production) names the expected
environment; --json prints rullst.deployment-diagnostic.v1.
Explicit environment sources remain separate. Exit zero covers only the inspected
local profile; deployment_verified remains false. See the
input and output contract before using it in CI.
cargo rullst inspect [target]
Scans source files and prints structural summaries in the terminal without starting a server, expanding macros or connecting to a database.
- Arguments:
[target]: The item or file to inspect (defaultroutes):routeorroutes: Listsget/post/put/deletedeclarations written asmethod("path" => handler)on one line in the regular.rsfiles undersrc/. Likeauditandgenerate:diagram, the walk does not follow symlinks and is bounded in depth and entries; it reports when a bound left the table incomplete.modelormodels: Lists the structs, enums andpubfields declared insrc/models.schema: Prints, as JSON, the table, fields, Rust types and optionality of every#[derive(Orm)]struct undersrc/(the extractormake:migration:autouses). It describes the models, not the live database. A project-providedrullst-schema.jsonis printed instead when present; Rullst does not generate that file.<path/to/file.rs>: Displays the first 40 lines of any target Rust file with line numbers.
- Optional Flags:
--json: Printsroutes,modelsorschemaas one versioned document (modelsandschemasince 13.0.0-alpha.4); another target with--jsonis refused.
inspect routes --json prints rullst.cli-routes.v1:
{
"schema": "rullst.cli-routes.v1",
"schema_version": "rullst.cli-routes.v1",
"complete": true,
"incomplete_reason": null,
"routes": [
{ "method": "GET", "path": "/posts/{id}", "handler": "controllers::posts::show" }
]
}
complete is false when the bounded source walk stopped early or src/ is
missing, with the reason in incomplete_reason.
inspect models --jsonprintsrullst.cli-models.v1:complete,incomplete_reasonandfiles, one entry persrc/models/*.rsfile (notmod.rs, no symlinks) with its publicstructandenumitems. A struct hasfields(name,type); an enum hasvariants. A file that is not valid Rust is named inincomplete_reason.inspect schema --jsonprintsrullst.cli-schema.v1: the samesourceandmodelsas the human output (struct,table,fieldswithname,rust_type,optional), or, when the project providesrullst-schema.json,source: "rullst-schema.json"with the parsed file undersnapshot.
🚀 5. Development, Infrastructure, and Build
cargo rullst dash
Opens the Ratatui development control surface in an interactive terminal. The dashboard reports the probed application port, supervised auto-reload state, the child process exit state, and the configured database profile; it does not label a database as connected merely because a URL exists. Logs and input queues are bounded, ANSI control sequences are removed, terminal state is restored on error, and the owned application process is stopped and reaped when the dashboard exits.
Live metrics (v13). Once a second the dashboard reads
GET /_rullst/dev-telemetry from the application it started, on
127.0.0.1:<port>, and shows only what the application reports:
- requests per second over the last 10 s, from the application’s exact request counters, and 4xx/5xx totals since the process started;
- errors: 5xx responses and their share of all requests over the last 60 s;
- p50/p95 latency over the last 60 s and a p95-per-poll sparkline (up to two
minutes), computed from the individual requests the dashboard observed. They
are marked
sampledwhen more requests arrived between two polls, or before the first poll of a process, than the 64 newest the application returns; - the newest requests with status, method, duration and path (never the query string);
- ORM queries since start and slow ORM operations (at least 100 ms);
- possible N+1 queries (see N+1 query warning);
- the pending jobs of a queue.
Counters, latency samples and slow operations restart with every new process
(a rebuild or r); the request list and the sparkline keep earlier entries.
Histories are bounded: 120 sparkline points, 50 requests, 16 slow operations and at most
4,096 latency samples. A response larger than 256 KiB, with another schema or
with inconsistent counters is rejected, and control or bidirectional-formatting
characters are replaced before anything reaches the terminal. A value the
application does not report is shown as not reported, never as zero. Only
the process the dashboard started is measured: a response carrying another
RULLST_DEV_GENERATION, for example from a second dev/dash already
listening on the port, replaces the metrics row with an ANOTHER PROCESS ON
THE PORT panel instead of showing that process’s figures.
The endpoint exists only in a debug build running in Development under
cargo rullst dev/dash; see the
telemetry guide. When the
application does not serve it, the metrics row is replaced by a TELEMETRY NOT
AVAILABLE panel with these steps:
- Serve the application with
rullst::Serverfrom Rullst 13 or newer (the generated starters do). - Run a debug build in Development: leave
RULLST_ENV/APP_ENVunset or setRULLST_ENV=development. - Press
r(or save a file) to restart.
| Value | Reported when | To enable it |
|---|---|---|
| Requests, latency, errors | The endpoint answers. | The steps above. |
| ORM queries and slow operations | Rullst’s default tracing subscriber is installed (Server::run installs it when the application has none) and keeps rullst_orm INFO spans. | Do not install another global subscriber first, and do not filter out rullst_orm=info with RUST_LOG. |
| Queue pending | The application passes its queue to the server. | Server::new(router).with_dev_queue(queue) (accepts a Queue or an Arc<Queue>). |
ORM queries count the outermost rullst.orm.query span of each ORM operation
(model queries, saves, deletes and Orm::raw). A chunk/chunk_by_id
traversal is not counted itself: each page it fetches and each operation its
handler runs counts separately. One operation can run several SQL statements,
and SQL executed directly through SQLx is not counted.
N+1 query warning
When the application reports that one request ran the same ORM operation at
least three times (the threshold Studio’s trace view uses), a POSSIBLE N+1
QUERIES panel appears below the logs. Each line shows the request’s method
and matched route (GET /posts/{id}), the operation fingerprint
(Comment.select_many (comments), built from the ORM span’s model, operation
and table labels; Rullst never records SQL text or bound values) and the
repetition count, followed by a hint to load the related rows eagerly or batch
the lookups into one query. Inspect the handler before changing it: repeating
an operation can be intentional. Raw statements (Orm::raw) and operations
started on another task (tokio::spawn) are not attributed to a request, so
they never produce a finding. The VERIFIED PROJECT STATE panel shows the
detection state in its N+1 check line: the number of findings since the
process started, N+1 detection needs request-correlated ORM telemetry (Rullst Core 13) when the application’s telemetry has no request-correlated ORM data
(an older Rullst Core), or N+1 detection needs ORM query telemetry when ORM
queries themselves are not reported (see the table above).
Performance note: a request that runs one ORM lookup per row (for example
loading each post’s comments inside a loop over posts) makes one database round
trip per row. Eager loading runs one
related-model query for all parents of a batch, and an IN query can fetch
the rows of several keys at once; measure the route before and after the
change.
Terminals at least 26 rows tall show the metrics row and terminals at least 105 columns wide add the recent-requests panel; shorter terminals show a one-line summary in the header. The layout adapts to narrower terminals and provides these keyboard controls:
r: restart the application from the current build (no rebuild). It is refused, with a notice, while the application is still starting, while a migration runs and while a saved change is being rebuilt (a successful rebuild restarts the application itself). The notice clears once the restarted application is ready, or says that its readiness was not confirmed or that it exited.o: open the application.s: probe the loopback Studio endpoint and open it only when reachable.d: open existing Scalar docs. Missing files produce explicitcargo rullst make:scalarguidance rather than silently modifying the project.m: rundb:migrateasynchronously and report its real exit result./: search both log panes;fcycles all/warning+error/error filtering.Tab: switch the focused log pane; arrows and Page Up/Page Down scroll it.c: clear dashboard logs.?: keyboard and metrics help. Any key closes it;qstill quits.qorEsc: exit (Esccloses the help first).
The footer lists d and Tab only on terminals at least 120 columns wide; the
help always lists every key.
The animated neon palette is enabled only for an interactive terminal. Set
RULLST_REDUCED_MOTION=1 (or true/yes) to stop the animation and keep the
colors. NO_COLOR removes the colors only; set both for a color-free, static
interface. Non-interactive automation should use
cargo rullst dev; dash fails clearly when no terminal is attached.
cargo rullst dev
Builds and starts a directly linked application. Saving source, static assets,
templates, Cargo.toml, Cargo.lock, Rullst.toml or .env schedules a
coalesced rebuild. A failed build leaves the current application running. A
successful build creates an owned executable snapshot, stops the previous
process and starts its replacement. The snapshot avoids locking Cargo’s build
output on Windows. Initial migrations run before startup; later migrations
remain an explicit command.
The same-origin browser client polls an opaque process-generation marker and
refreshes only when a different generation serves successfully. This is enabled
only in debug/development. Readiness verifies that marker, not just an open port.
Changing the configured port requires restarting the CLI. In-memory state and
unsaved browser state reset during reload. The process receives a bounded
shutdown interval before forced termination; this is a development facility.
Ctrl+C, SIGTERM (an IDE stop button or kill) and SIGHUP (a closed terminal)
end dev and dash the same way: the application’s process group is stopped
and its executable snapshot removed (on Windows, Ctrl+C and closing the console).
No scaffold question is required: dev and dash enable auto-reload, while
cargo run runs the application normally. The legacy --hot-reload scaffold
flag is rejected (since v12) because DLLs can split ORM/Tokio globals. Existing
legacy scaffolds can use their directly linked router; the supervisor removes
HOT_RELOAD from its child’s environment.
See Supervised Development Auto-Reload for limitations, failure recovery and the v13 architecture decision.
dev keeps its plain output; at startup it prints one line suggesting
cargo rullst dash for live requests, latency, errors and database metrics.
- Optional Flags:
--ts-sync: Regenerates the TypeScript client SDK (rullst-client.ts, asgenerate:tswrites it from the routes insrc/main.rsandsrc/lib.rs) after the initial build and after every successful rebuild. A failed generation is reported and the application keeps running.
cargo rullst build:client
Builds the library for wasm32-unknown-unknown, runs wasm-bindgen, and writes a
separate static/rullst-islands.js hydrator that awaits binding initialization.
It parses Cargo.toml, merges the required cdylib crate type without replacing
existing library crate types, and honors an explicit lib.name. The command
checks/installs the Rust target and wasm-bindgen-cli; any failed tool step
aborts. Bundle size and browser performance depend on the generated application
and must be measured.
- Flags:
--debug(builds the Wasm library with Cargo’s debug profile instead of--release).
cargo rullst build
Creates the monolithic final Production binary of the backend and writes Brotli
(.br) and Zstandard (.zst) siblings next to the html, css, js, json,
svg, wasm, xml and txt files under static/. The server prefers a
sibling over its source, so rerun the command after editing an asset and before
building an image; cargo rullst dev removes siblings that are not newer than
their source at startup and on every change (siblings without a source file are
kept).
- Flags:
--debug(builds with Cargo’s debug profile instead of--release).
cargo rullst green
Maturity: Experimental. It replaces cargo rullst footprint (13.0.0-alpha.1
to alpha.3) and is part of the main CLI, so it needs no separate install.
Reports figures it measured for the app, each with its method: the release
binary size, a local container image’s size (--image, through docker or
podman image inspect), the idle resident memory of the started app (Linux
VmRSS), the third-party dependency count from Cargo.lock and, with
--bench, the app process’s CPU time during a bounded closed-loop loopback
load and the CPU time per request. Anything unavailable is NOT MEASURED with
the reason. It measures no energy: a Software Carbon Intensity (SCI,
ISO/IEC 21031:2024) figure appears only as an estimate labelled estimate,
computed from the measured CPU time, your --cpu-watts and your
--grid-intensity, with every input shown.
Without --url it builds the release binary (reusing an existing build),
starts it with RULLST_ENV=production (or --env staging, which accepts the
starters’ mock billing credentials) on a free 127.0.0.1 port, measures it
and stops it. With --url it measures an app already running on loopback and
refuses any other host; it makes no network calls beyond loopback and the
local container engine, and never fetches grid intensity.
cargo rullst green --image my-app:latest
cargo rullst green --url http://127.0.0.1:3000 --path /health --bench --json
cargo rullst green --bench --cpu-watts 15 --grid-intensity 120
- Flags:
--url <http://127.0.0.1:PORT>,--env <production|staging>(defaultproduction; not with--url),--path <PATH>(default/),--image <NAME>,--bench,--duration <10s|1m|1500ms>(default10s, 1 s to 10 min; needs--bench),--concurrency <N>(default 4, at most 256; needs--bench),--cpu-watts <W>(needs--bench),--grid-intensity <gCO2e/kWh>(needs--cpu-watts),--embodied <gCO2e>(needs--grid-intensity),--json(versionedrullst.green.v1). - Exit status:
0when the measurement ran, even withNOT MEASUREDfields;1when the build failed or the app could not be started or reached;2for invalid arguments.
See the green guide for what each figure means and how to choose a grid intensity.
cargo rullst dockerize
Writes the Dockerfile (plus .dockerignore when absent) into an existing
project, as new --docker does. It refuses to replace an existing
Dockerfile; move a customized file aside to regenerate the template. The
binary is named after [package].name, read with a TOML parser (app when
Cargo.toml has no package name). As in deploy, a project whose
Rullst.toml (or .env.example) names a SQLite database gets
DATABASE_URL=sqlite:///app/data/rullst.db?mode=rwc in the image. cargo rullst deploy writes the same Dockerfile together with compose.yaml and the
Caddyfile.
Removed in 13.0.0-alpha.4: nixify, generate:buildah, foundry:init,
foundry:deploy and make:grpc. Files they generated stay yours; see
Advanced deployment for Nix, Buildah, Podman
Quadlet and Kubernetes.
🛡️ 6. Security, Compliance & System Diagnostics
cargo rullst audit
Executes bounded automated checks across recognized source, configuration, route, dependency, and local network patterns, and can invoke installed dependency scanners. Static findings require human review and are not a penetration test or compliance certification.
- Optional Flags:
--ai: Prints fixed, rule-based remediation suggestions after the checks. It calls no AI model or network service; the flag keeps its legacy name.--compliance: WritesSECURITY_COMPLIANCE.md, an evidence report. Each executed check isNO FINDINGS,NO FINDINGS OUTSIDE EXCEPTIONS,FINDINGS,GENERATED,OBSERVED,NOT CHECKED, orERROR, and control families outside the command’s scope areNOT EVALUATED. It never reportsPASSand is not a SOC 2, ISO 27001, or transport certification.--idor: Fails on parameterized routes without an explicit adjacent// rullst-access: public|owner|role|admin — reasonclassification.ownerrequiresRbacGuard::authorize_owner_or_role;rolerequires a recognized role guard;adminrequiresRequireRoleLayerorNexusAuthPolicy::protect_router;publicis restricted to recognized GET routes. The marker goes on the route’s line or the line above it; in a multi-line.route(call, on the line above the path literal. The guard may appear anywhere in the same crate’ssrctree outside comments and#[cfg(test)]items, so the check does not prove that the route is mounted behind it. Manual review and runtime negative tests remain required.--geiger: Inventoriesunsafein the dependency tree. Unsafe may be justified and requires review; the command does not prove a zero-unsafe invariant.--sbom: Generates a standardized CycloneDX 1.5 JSON Software Bill of Materials (sbom-cyclonedx.json) fromCargo.lock, with the SHA-256 checksums the lockfile records. It contains no license metadata.--audit-ignore RUSTSEC-YYYY-NNNN: Passes one explicit, repeatable advisory exception tocargo audit. A successful run is reported as NO FINDINGS OUTSIDE EXCEPTIONS, not “no findings”; the caller must separately version, own, review, and expire every exception.--network: Checks a bounded list of local ports/bindings for potentially exposed services; it is not a comprehensive network scan. The TCP listener inventory runsss -ltnH(Linux iproute2). Where it cannot run, as on macOS, Windows or a Linux image without iproute2, the check is reported asERRORand the command exits non-zero instead of reporting a clean scan.--report [md|html|json](new in 13.0): Runs the static report checks below (mapped to OWASP ASVS 5.0.0 Level 1, with a personal-data inventory and accessibility heuristics) and writesSECURITY_REPORT.md(the default),SECURITY_REPORT.htmlorSECURITY_REPORT.jsonin the current directory;--output <path>chooses another file (its directory must exist). It reuses thecargo auditstep with its--audit-ignoreexceptions and the--idorscanner, and cannot be combined with--json,--compliance,--sbom,--network,--geigeror--ai. See the security report guide.--json: Moves the progress lines to standard error and prints onerullst.cli-audit.v1summary on standard output; the exit status is unchanged (non-zero for findings or incomplete requested checks):
{
"schema": "rullst.cli-audit.v1",
"schema_version": "rullst.cli-audit.v1",
"issues_found": 0,
"checks": [
{ "id": "secret_scan", "status": "not_checked", "count": null, "exceptions": [], "detail": "no .env file was available to inspect" },
{ "id": "dependency_audit", "status": "no_findings_outside_exceptions", "count": null, "exceptions": ["RUSTSEC-2099-0001"], "detail": "..." }
]
}
Check ids are secret_scan, dependency_audit, unsafe_scan, idor_scan,
sbom and network_scan, always in that order; status is no_findings,
no_findings_outside_exceptions, findings, generated, observed,
not_checked or error, and count is set for findings, generated and
observed. Details are redacted like error reports.
In a package directory, the unsafe and IDOR/BOLA scans cover its src and the
src of every workspace member below it, as listed by cargo metadata; in a
directory without src, such as a virtual workspace root, the IDOR/BOLA scan
walks every src tree below it. The source scans (unsafe syntax, IDOR/BOLA
routes and listener bindings) do not follow symlinked files or directories and
skip target/ and .git/. A walk
stops at 64 directory levels or 250,000 entries; reaching either bound is
reported as a finding, so the scan fails as incomplete instead of passing. The
route and listener scans skip each top-level #[cfg(test)] item (such as
mod tests; or an inline test module) on its own; code after it is still
scanned.
cargo audit and the SBOM read the project’s own Cargo.lock. In a workspace
member, which has none, they read the lockfile of the workspace root that
cargo metadata reports, and cargo audit receives it as --file; the SBOM
then lists every package of that workspace lockfile.
SBOM components come from Cargo.lock. Only crates.io packages receive the
plain pkg:cargo/<name>@<version> purl; a package from another registry adds a
repository_url qualifier, a git package adds a vcs_url qualifier with the
locked commit, and path or workspace packages (including the application) have
no purl. Every component that is not from crates.io carries a
rullst:cargo:source property with its lockfile source, or local.
SECURITY_COMPLIANCE.md and sbom-cyclonedx.json are written in the current
directory and replace a previous regular file. Because an audit may run on an
untrusted checkout, the command refuses to write either file through a symlink.
Security report (--report)
The report maps each check to OWASP ASVS 5.0.0
(release v5.0.0_release) Level 1 requirement IDs and section names, and
lists the Level 1 requirements no check relates to as NOT EVALUATED, by
chapter. A header states that it does not replace a manual review or a
penetration test; it never reports PASS or a certification.
| Check id | What it reads | ASVS 5.0.0 |
|---|---|---|
security_headers | Server::new or a header layer in src; [security] csp/coep in Rullst.toml | V3.4.1 (L1); V3.4.3, V3.4.4, V3.4.6 (L2) |
csrf | Single-line write routes; Server, csrf_middleware, csrf_signed_webhook_paths | V3.5.1 (L1) |
cookies | Set-Cookie-shaped string literals, cookie builders, csrf_same_site | V3.3.1 (L1); V3.3.2, V3.3.4 (L2) |
auth_rate_limit | Authentication write routes; a rate limiter or LoginGuard | V6.3.1 (L1) |
committed_secrets | Files from git ls-files (not target/, binaries, symlinks or files over 2 MiB) | V13.3.1 (L2), chapter V13 |
dependency_audit | cargo audit with the --audit-ignore exceptions | V15.2.1 (L1) |
idor | The --idor route-classification scan | V8.2.1, V8.2.2 (L1) |
a11y_img_alt, a11y_form_labels, a11y_html_lang | html! bodies and templates under src/ and templates/ | WCAG 2.2 1.1.1; 1.3.1, 3.3.2, 4.1.2; 3.1.1 |
Each check reports no_findings, no_findings_outside_exceptions,
findings, not_checked or error (shown as NO FINDINGS, FINDINGS, …
in Markdown and HTML) with a one-line fix and a link to the guide. A
committed-secret finding shows only the file, the line and the first four
characters of the value followed by …. The personal-data inventory
(#[privacy], #[orm(encrypted)], SecretString, #[orm(masked)], and
review for unclassified fields named like email, phone, cpf, cnpj, ssn,
birth, address, document or ip) is observed and never changes the exit
status.
Exit status: 1 when any check reports findings or error; 0 otherwise,
including when checks are not_checked (no src/, no Git work tree, no
cargo-audit). The terminal prints a grouped ✓/!/✗ summary, plain under
NO_COLOR, CI, TERM=dumb or a non-terminal stdout.
Formats: Markdown has one GitHub table per section; HTML is one
self-contained file (inline CSS with light and dark schemes, no script, no
external asset, every value escaped); JSON is the rullst.cli-audit-report.v1
document below, without ANSI sequences:
{
"schema_version": "rullst.cli-audit-report.v1",
"generated_at": "2026-10-08T12:00:00+00:00",
"tool": { "name": "cargo-rullst", "version": "13.0.0" },
"notice": "This report records bounded static checks for a reviewer. ...",
"standard": { "name": "OWASP Application Security Verification Standard", "version": "5.0.0", "level": 1, "source": "https://github.com/OWASP/ASVS/releases/tag/v5.0.0_release", "level1_requirements": 70, "level1_mapped": 7, "mapping_note": "..." },
"accessibility_standard": "WCAG 2.2",
"exit_non_zero": true,
"checks": [
{ "id": "csrf", "group": "security", "title": "CSRF", "status": "findings", "count": 1, "exceptions": [], "detail": "...", "fix": "...", "docs": "https://rullst.github.io/Rullst/book/security-report.html#csrf", "asvs": [{ "id": "V3.5.1", "section": "V3.5 Browser Origin Separation", "level": 1 }], "asvs_chapter": null, "wcag": [], "findings": [{ "file": "src/lib.rs", "line": 12, "message": "...", "preview": null }] }
],
"personal_data": { "status": "observed", "count": 2, "review": 1, "detail": "...", "docs": "...", "asvs_chapter": "V14 Data Protection", "fields": [{ "model": "User", "field": "email", "file": "src/models/user.rs", "line": 8, "classification": "review", "encrypted": false }] },
"not_evaluated": [{ "chapter": "V1", "name": "Encoding and Sanitization", "status": "not_evaluated", "whole_chapter": true, "requirements": ["V1.2.1", "..."] }]
}
checks always holds the ten ids of the table above, in that order. A new
key or a changed meaning needs a new schema_version.
Limits: every check is a bounded static heuristic. Routes are recognized
only when declared on one line; limiter, guard and header evidence is
project-wide and does not prove a route is mounted behind it; untracked files
and Git history are not scanned for secrets; templates are not rendered and
includes are not followed. The source walks share the audit bounds (no
symlinks, 64 levels, 250,000 entries) and an incomplete walk turns the source
checks into error. Files over 2 MiB are not read.
cargo rullst hook:install
Installs managed pre-commit and commit-msg wrappers. The first runs
cargo fmt --all -- --check, strict workspace Clippy, and
cargo rullst audit --idor; the second enforces Conventional Commits while
accepting the subjects Git itself writes for merges (Merge branch,
Merge remote-tracking branch, Merge tag, Merge pull request, …),
reverts (Revert "...") and fixup!/squash!/amend! commits. Existing
active hooks are moved to explicit .rullst-original backups and invoked first,
while reinstalling the managed wrappers is idempotent. The command supports
linked worktrees, fails clearly outside a Git worktree, and refuses a backup
collision instead of overwriting it. When core.hooksPath (local, global or
system Git configuration, as used by Husky or shared hook directories) selects
another directory, it fails before writing, because Git would never run wrappers in the
default hooks directory; call the checks from that hook manager instead. These local hooks are bypassable by design;
protected CI remains authoritative.
cargo rullst doctor
Runs grouped health checks and shows each one as ✓ (pass), ! (warning),
✗ (failed) or · (information, optional or not applicable). Every warning and
failure carries a fix (one line, or a short configuration snippet) and a link
to its group below. The command
exits with status 1 when any check failed; warnings do not fail it.
--fix installs missing rustfmt/clippy components with
rustup component add rustfmt clippy, then checks again. Outside a project
only the toolchain, project, security (cargo-audit) and disk groups appear.
Checks are local and bounded: tool probes are --version calls run in
parallel, the database probe is a 2-second TCP connect (no credentials are
sent) and a SQLite file is opened read-only. Kani is only looked up as a
cargo-kani executable (in Cargo’s bin directory or on PATH), never run:
cargo kani starts its first-time download and toolchain installation when it
is not set up yet. Values from .env are measured,
never printed.
Doctor: toolchain
rustc (✗ when missing or older than the 1.96.0 MSRV), Cargo, rustfmt and
clippy (fixable with --fix), Git, the wasm32-unknown-unknown target when
the project has src/islands, and one informational line for the optional
tools (cargo-deny, cargo-geiger, cargo-mutants, Kani, cargo-llvm-cov, Docker).
On Linux, toolchain.linker (v13) is a ! hint, never a failure, when no
fast linker is selected: no -fuse-ld=mold, -fuse-ld=lld (or
--ld-path= naming one of them) and no linker entry in [build], the host’s
[target.<triple>] or a [target.'cfg(..)'] table of any .cargo/config.toml
(or legacy .cargo/config) from the working directory up, or of
$CARGO_HOME/config.toml (~/.cargo by default), and none in RUSTFLAGS,
CARGO_ENCODED_RUSTFLAGS, CARGO_BUILD_RUSTFLAGS or the host’s
CARGO_TARGET_<TRIPLE>_RUSTFLAGS/_LINKER. It says whether mold or ld.lld
is on PATH and shows the snippet to add, for example:
[target.aarch64-unknown-linux-gnu]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
If the file already has that table, add the two flags to its rustflags
instead of repeating the table. The check is skipped on other operating systems
and on x86_64-unknown-linux-gnu with Rust 1.90 or newer, where rustc already
links with its bundled LLD by default.
Doctor: project
The enclosing Rullst project (a Cargo.toml that depends on rullst) and its
rullst requirement compared with the CLI’s major version; an older project
suggests cargo rullst upgrade --dry-run.
Doctor: config and .env
Whether .env parses, which keys listed in .env.example are set neither in
.env nor in the process environment (names only), whether RULLST_ENV
(or APP_ENV) names a known environment, and whether Rullst.toml is valid.
Doctor: database
The primary database from DATABASE_URL (environment, then .env, then
[database].url in Rullst.toml): a SQLite file must exist or be creatable;
PostgreSQL and MySQL/MariaDB must accept a TCP connection (✗ otherwise).
REDIS_URL is probed the same way (! when unreachable). Turso/remote and
mock_* endpoints are not contacted.
Doctor: migrations
The src/migrations/m*.rs files and, for a local SQLite database, which of them
the ORM migrations table has not applied yet (cargo rullst db:migrate).
For server databases run cargo rullst db:status.
Doctor: security baseline
cargo-audit installed, .env neither tracked nor unignored by Git (✗ when
tracked), APP_KEY set to at least 32 characters and not a template
placeholder, and a Cargo.lock for reproducible builds.
Doctor: disk space
Free space on the filesystem of the project (or the working directory): ✗ below 1 GiB, ! below 5 GiB. Not measured on Windows yet.
cargo rullst doctor --json prints one document on standard output and keeps
the exit status rules:
{
"schema": "rullst.cli-doctor.v1",
"schema_version": "rullst.cli-doctor.v1",
"cli_version": "13.0.0",
"ok": false,
"summary": { "pass": 11, "warn": 5, "fail": 1, "info": 2 },
"groups": [
{
"id": "database",
"title": "Database",
"checks": [
{
"id": "database.primary",
"title": "Primary database",
"status": "fail",
"detail": "PostgreSQL at 127.0.0.1:5432: connection refused",
"fix": "Start the server (for example `docker compose up -d`) or fix the URL in .env",
"docs": "https://rullst.github.io/Rullst/book/cli_reference.html#doctor-database"
}
]
}
]
}
status is pass, warn, fail or info; fix and docs are null for
passing checks. Group ids are toolchain, project, config, database,
migrations, security and disk; check ids (toolchain.rustc,
config.env_keys, security.app_key, …) are stable, but a check appears
only when it applies.
cargo rullst academy:doctor
Evaluates the twelve requirements of ProductionPreset::academy() (identity,
school membership, entitlements, object authorization, tenant isolation,
assessments, score events, durable automation and audit, content safety,
privacy and distributed abuse controls) against the declarations in a
rullst.academy-evidence.v1 file passed with --evidence <file>. It reads
only that file and never inspects the project, so it needs no Academy
scaffold: v13 removed the complete Academy scaffold, and the LMS example
(the former LMS starter) has no evidence file. Without --evidence every requirement is
NOT_EVALUATED.
Each requirement is reported as PASS, FAIL, SKIPPED or NOT_EVALUATED.
Unknown fields or requirements, a repeated requirement and a PASS without a
non-empty evidence reference are rejected. --json prints
rullst.academy-diagnostic.v1 with certification (always false),
contract_satisfied and each requirement’s status and declared_evidence.
The command exits with status 1 until every requirement has an evidenced
PASS. Evidence strings are caller declarations, not verified proof; see the
Academy production-boundary diagnostic.
🛠️ Quick CLI Cheat Sheet
# Create a new project (the wizard asks for the starter, database and features)
cargo rullst new my_app
# Reverse-engineer ORM models from an existing database
cargo rullst generate:models --driver postgres --url "postgres://user:pass@localhost:5432/mydb"
# Statically inspect routes, models, or schemas in the terminal
cargo rullst inspect route
cargo rullst inspect model
# Open the local Studio compatibility UI on 127.0.0.1:5555
cargo rullst studio
# Map the project (AGENTS.md, .llms.txt) for your editor's or terminal's AI assistant
cargo rullst generate:ai-context
# Write the Dockerfile, compose.yaml and Caddyfile for a Linux VM (Docker or Podman)
cargo rullst deploy
Cargo feature matrix
Important
This page describes the v13 prerelease source, and its dependency examples pin the published
13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and a plain"13.0.0-alpha.4"would also accept later alphas (see the v13 migration guide). The v12 line reached end of life on 16 October 2026; see the release record. Commit Cargo.lock for reproducible builds, and use path dependencies only when intentionally testing checkout-local changes.
This page is the public feature contract for the 20 packages in the v13
release inventory: the 16 published 12.x
packages plus the rullst-privacy, rullst-supervision, rullst-media and
rullst-labs candidates, first published in the 13.0.0-alpha.1 prerelease.
The package manifests remain the machine-readable
source of truth. The matrix explains the behavior those names select in this
v13 source and makes the default build visible before an application adopts
optional integrations.
Cargo features are additive across a dependency graph. An application can disable a package’s defaults at the dependency edge, but it cannot disable a feature enabled by another dependency. Inspect the final selection with:
cargo tree -e features
The release gates compile every package with no default features, every public
umbrella feature in isolation, representative domain-package boundaries, and
the complete workspace with all features. The isolated umbrella list, and this
page’s umbrella table and default features, are checked automatically against
rullst/Cargo.toml, so a newly added public feature cannot silently escape the
matrix. See
check-feature-boundaries.sh for
the exact individual checks.
v13 prerelease privacy additions
The v13 candidate adds rullst-privacy as an optional release package. These
features are absent from published 12.x releases and first shipped in the
13.0.0-alpha.1 prerelease, which requires an explicit version opt-in. They do not change
default dependencies or select application policies automatically.
| Umbrella feature | Standalone privacy feature | Contract |
|---|---|---|
privacy | None | Independent empty base |
privacy-age | age-assurance | Proportional age-policy contracts |
privacy-challenge-tokens | challenge-tokens | Authenticated server challenge transport |
privacy-sqlite | sqlite | Shared-local age replay protection |
privacy-postgres | postgres | Age replay protection on one authoritative PostgreSQL database |
privacy-consent | consent | Purpose/version choices and effective withdrawal |
privacy-consent-sqlite | consent-sqlite | Shared-local consent state |
privacy-consent-postgres | consent-postgres | New candidate for shared consent across application hosts; independent of age assurance |
See the privacy package guide for initialization, runtime roles, data minimization, backup/failover obligations and acceptance.
The local email-login candidate adds Auth email-login-sqlite and
email-login-postgres, with facade auth-email-login-sqlite /
auth-email-login-postgres. These optional paths reuse authoritative recovery
accounts and opaque sessions; they do not enable email login on accounts or
replace tenant/MFA policy. Hosted source/package admission remains pending; see
the email-login contract.
The scoped API-token candidate adds Auth api-tokens-sqlite /
api-tokens-postgres and facade auth-api-tokens-sqlite /
auth-api-tokens-postgres. They enable the corresponding recovery backend only;
email login, JWT and OAuth remain independent. See the
API-token contract for current admission and boundaries.
Umbrella crate: rullst
The v13 prerelease private-storage candidate adds Core/facade storage-multipart,
which selects storage-s3, bounded XML parsing and checkpoint key zeroization.
It enables no database, queue or default feature. See
private multipart uploads for server-mediated
parts, encrypted resumable checkpoints, cleanup responsibilities and admission.
The default rullst dependency enables no feature: since 13.0.0-alpha.3 it
compiles the HTTP runtime without the ORM, a SQLx driver or Core’s durable
SQLite queue, so a database-free application builds no database code. Up to
13.0.0-alpha.2 the defaults were orm, drivers-all and queue-sqlite.
Select what the application uses:
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = ["strict-sqlite", "queue-sqlite"] }
Each strict-* backend enables orm; drivers-all enables orm with all
three drivers. orm itself adds no SQLx driver: it compiles, but Orm::init
and Server::with_db then fail at startup for every URL scheme whose driver
no other enabled feature happens to add (queue-sqlite, for example, adds only
SQLite), with an error that names the feature to enable. default-features = false keeps the same graph on 13.0.0-alpha.2, whose defaults still included
the drivers.
| Feature | Default | Enables |
|---|---|---|
orm | no | rullst-orm and Core’s ORM integration, without a SQLx driver; pair it with drivers-all or one strict-* backend |
drivers-all | no | orm plus the SQLite, PostgreSQL and MySQL/MariaDB SQLx drivers in the ORM and Core, and in Studio and Nexus when studio or nexus is enabled |
orm-mongodb | no | orm plus the MongoDB document adapter |
orm-duckdb | no | orm plus the in-process DuckDB analytics adapter |
orm-turso | no | orm plus typed Turso-primary CRUD/query, parameterized remote libSQL SQL over Hrana HTTP v3, transactions, reversible checked migrations, and a persistent offline fallback |
orm-surrealdb | no | orm plus SurrealDB HTTP document and bounded graph adapters |
orm-scout | no | orm plus bounded Meilisearch, Elasticsearch and Algolia Scout HTTP adapters |
orm-pgvector | no | orm plus typed pgvector SQLx values; use with strict-postgres for the supported live query contract |
orm-qdrant | no | orm plus bounded dense-vector Qdrant HTTP operations and offline fallback |
orm-redis | no | orm plus namespaced Redis Hash, Set and Sorted Set operations |
queue-sqlite | no | Core’s durable SQLite queue backend |
storage-s3 | no | v13 candidate: Core’s bounded AWS S3 and Cloudflare R2 object operations and signed GET URLs; see private object storage |
storage-multipart | no | v13 candidate: storage-s3 plus server-mediated multipart uploads with bounded XML parsing and checkpoint key zeroization; see private multipart uploads |
nexus | no | The generated Nexus administration interface, without the AI assistant (since 13.0.0-alpha.4) |
nexus-ai | no | v13: nexus plus the AI assistant at /nexus/chat (rullst-nexus/ai), which adds rullst-ai; without it /nexus/chat answers 404 with a notice naming this feature |
nexus-tz | no | v13: nexus plus NexusTimeZone::named, so DateTimeUtc form values are shown and read in a named IANA time zone with its daylight-saving rules; adds jiff with the system zoneinfo database and a bundled fallback |
studio | no | Studio plus Core’s Studio integration marker |
auth | no | Authentication, sessions, passkeys, and RBAC helpers from rullst-auth |
auth-jwt | no | auth plus the strict application-issued JWT policy |
auth-sqlite | no | auth-jwt plus bounded shared SQLite JWT revocation and passkey device lifecycle state |
auth-passkey-postgres | no | Optional v13 account/session-bound PostgreSQL passkey ceremony candidate; host credential-counter CAS remains required |
auth-sessions-sqlite | no | v13 candidate: auth plus the SQLite account registry, opaque sessions and session inventory (Auth recovery-sqlite); see session management |
auth-sessions-postgres | no | v13 candidate: auth plus the PostgreSQL account registry, opaque sessions and session inventory (Auth recovery-postgres) |
auth-email-login-sqlite | no | v13 candidate: auth plus email login over the SQLite recovery accounts and sessions; see the email-login contract |
auth-email-login-postgres | no | v13 candidate: auth plus email login over the PostgreSQL recovery accounts and sessions |
auth-api-tokens-sqlite | no | v13 candidate: auth plus scoped API tokens over the SQLite recovery backend; see the API-token contract |
auth-api-tokens-postgres | no | v13 candidate: auth plus scoped API tokens over the PostgreSQL recovery backend |
account-mail-sqlite | no | auth and mail plus the SQLite recovery store and the account_mail delivery bridge; see account mail |
account-mail-postgres | no | auth and mail plus the PostgreSQL recovery store and the account_mail delivery bridge |
mail | no | rullst-mail with HTTP/offline transports and no SMTP dependency |
mail-sqlite | no | mail plus bounded shared-local SQLite recipient suppression and provider-event replay evidence |
mail-postgres | no | v13 candidate: shared PostgreSQL suppression with keyed identifiers, authoritative dispatch checks and independent quotas |
mail-smtp | no | mail plus the optional SMTP transport |
mail-aws-ses | no | mail plus native SES v2 delivery signed by the official AWS SDK |
messaging | no | Native bounded broker-neutral messaging contracts and the deterministic process-local broker |
messaging-sqlite | no | messaging plus fixed-schema durable local SQLite publication, lease, retry/DLQ, ACK and idempotency state |
messaging-schedules-postgres | no | v13 candidate: encrypted PostgreSQL recurring-publication outbox and fenced relay into an explicitly selected broker; no default SQLite/ORM |
messaging-webhooks | no | v13 candidate: encrypted shared-local SQLite outgoing outbox, immutable HTTPS destination, signed exact bytes, fenced retries and minimized terminal inspection |
messaging-orm-outbox | no | messaging and orm plus the static relational outbox-to-broker relay; the publish/ACK crash window remains at-least-once |
messaging-redis | no | v13 candidate: messaging plus the Redis Streams broker with fenced delivery indexes; enables no ORM, mail, cache or Core queue; see Redis Streams messaging |
privacy | no | v13 candidate: the independent empty rullst-privacy base; the privacy-* features are listed in the privacy additions |
privacy-age | no | v13 candidate: privacy plus proportional age-policy contracts |
privacy-challenge-tokens | no | v13 candidate: privacy-age plus authenticated server challenge transport |
privacy-sqlite | no | v13 candidate: privacy-age plus shared-local age replay protection |
privacy-postgres | no | v13 candidate: privacy-age plus age replay protection on one authoritative PostgreSQL database |
privacy-consent | no | v13 candidate: privacy plus purpose/version choices and effective withdrawal |
privacy-consent-sqlite | no | v13 candidate: privacy-consent plus shared-local consent state |
privacy-consent-postgres | no | v13 candidate: privacy-consent plus shared PostgreSQL consent state |
mailer | no | Compatibility alias for mail-smtp; prefer mail-smtp in new manifests |
queue-redis | no | Redis dependency and Core’s Redis queue backend |
cache-redis | no | Redis dependency and Core’s Redis cache backend |
redis | no | Convenience alias enabling queue-redis, cache-redis and orm-redis |
scheduler-tz | no | v13: Scheduler::task_in_tz and Scheduler::deferrable_task_in_tz, cron tasks (and deferral windows) in a named IANA time zone with a documented daylight-saving rule; adds jiff with the system zoneinfo database and a bundled fallback |
oauth | no | OAuth2/OIDC providers from rullst-connect with its Axum integration (rullst-connect/rullst): the AuthCallback extractor and the local mock IdP router |
oauth-sqlite | no | oauth plus bounded encrypted shared-local token-generation state with exact SQLite compare-and-swap, and the v13 SQLite login-challenge store (SqliteChallengeStore) |
oauth-postgres | no | v13: oauth plus the PostgreSQL login-challenge store (PostgresChallengeStore) over the application’s pool |
oauth-mysql | no | v13: oauth plus the MySQL/MariaDB login-challenge store (MySqlChallengeStore) over the application’s pool |
oauth-mock | no | v13: oauth plus rullst-connect/mock, so empty or mock_* provider credentials build an offline mock provider that signs in a fixed mock user; enable it only in development and test builds |
ai | no | Provider-agnostic AI clients and local safeguards from rullst-ai |
ai-sql-memory | no | ai plus tenant-aware durable chat memory for SQLite, PostgreSQL, MySQL, and MariaDB |
capital | no | Provider-neutral billing contracts, the Stripe and experimental InfinitePay adapters, webhooks and analytics from rullst-capital |
capital-actix | no | capital plus the Actix Web adapter for the canonical signed-webhook verifier |
capital-quota-sql | no | capital and orm plus atomic shared resource quotas for SQLite, PostgreSQL, MySQL, and MariaDB |
capital-webhook-sql | no | capital and orm plus bounded durable webhook replay/event claims for SQLite, PostgreSQL, MySQL, and MariaDB |
capital-pdf | no | capital plus bounded validated native invoice PDF rendering |
capital-mail | no | capital-pdf plus Mail’s payment-bound HTML/PDF attachment delivery bridge |
security | no | RASP/WAF and application-security primitives from rullst-security |
security-redis | no | security plus the atomic Redis rate limiter |
iot | no | IoT models, frame helpers, and signed OTA verification from rullst-iot |
telemetry | no | OpenTelemetry dependencies and Core’s OTLP integration |
strict-postgres | no | orm with the concrete PostgreSQL pool/backend selected |
strict-mysql | no | orm with the concrete MySQL pool/backend selected when PostgreSQL is not also selected |
strict-sqlite | no | orm with the concrete SQLite pool/backend selected when PostgreSQL and MySQL are not also selected |
The three strict-* backend features are supported as single selections;
each enables only its own SQLx driver. Feature unification can activate more
than one; the current deterministic precedence is PostgreSQL, then MySQL, then
SQLite. Do not depend on that precedence as backend negotiation. Select one
strict backend in an application, or select none and keep drivers-all to use
SQLx Any with all three drivers.
Shared-local SQLite composition profile
A bounded single-host application may compose the following umbrella features over one file-backed SQLite URL:
[dependencies]
rullst = { version = "=13.0.0-alpha.4", default-features = false, features = [
"auth-sqlite",
"capital-quota-sql",
"mail-sqlite",
"messaging-sqlite",
"oauth-sqlite",
"queue-sqlite",
] }
The stores use distinct fixed table namespaces. Initialize/check them
sequentially, keep application readiness false until every required component
is healthy, and reuse exactly the same URL, quotas, namespaces, and keys after
restart. Close every handle before file-level backup or restore. The dedicated
facade_recovery test proves restart,
idempotency, encrypted-secret plaintext absence, queue recovery, aggregate
readiness, and isolated fail-closed corruption for this exact profile.
Sharing the file is not a cross-domain transaction or a multi-host design. The host still owns permissions, encryption-key custody, a consistent whole-file backup procedure, recovery drills, contention policy, and domain authorization. Prefer separate databases when failure isolation or write throughput is more important than simple local operation.
Runtime and data crates
rullst-core
Default features: none.
| Feature | Enables |
|---|---|
orm | Optional rullst-orm and SQLx support, including Artisan and database-backed feature flags; it adds no SQLx driver |
drivers-all | orm plus the ORM’s SQLite, PostgreSQL and MySQL/MariaDB drivers |
queue-sqlite | SQLx-backed durable SQLite queues without enabling the full ORM facade |
queue-redis | Redis-backed queues |
cache-redis | Redis-backed cache storage |
redis | Convenience alias for both Redis queue and cache backends |
scheduler-tz | v13: Scheduler::task_in_tz and Scheduler::deferrable_task_in_tz for cron tasks (and deferral windows) in a named IANA time zone (system zoneinfo, bundled jiff database as fallback) |
storage-s3 | v13 candidate: bounded AWS S3 and Cloudflare R2 object operations and signed GET URLs |
storage-multipart | v13 candidate: storage-s3 plus server-mediated multipart uploads with bounded XML parsing and checkpoint key zeroization |
studio | Integration marker used by the umbrella Studio boundary; it adds no dependency by itself |
telemetry | OpenTelemetry tracing and OTLP export dependencies |
strict-postgres | orm plus the ORM PostgreSQL backend selection |
strict-mysql | orm plus the ORM MySQL backend selection |
strict-sqlite | orm plus the ORM SQLite backend selection |
Core’s process-local Radar and span collector do not require telemetry.
That feature is specifically for OpenTelemetry/OTLP integration.
The rullst.client v1 codec and bounded #[server_function] transport are
available without a feature flag; only the explicit generated route exists on
native targets, while the same annotated function becomes its Wasm caller.
Identity, authorization and tenant policy remain application layers.
rullst-orm
No default feature (13.0.0-alpha.2 and earlier defaulted to drivers-all).
With no strict-* feature, public pool and database aliases use SQLx Any,
which opens only the drivers compiled in; without any, Orm::init fails with
an error naming the feature to enable. The workspace and the umbrella crate
depend on the ORM with default-features = false and forward drivers-all
or a strict-* backend explicitly.
| Feature | Enables |
|---|---|
drivers-all | The SQLite, PostgreSQL and MySQL/MariaDB SQLx drivers |
redis | Redis query cache plus bounded namespaced Hash, Set and Sorted Set datastore operations |
mongodb | Official MongoDB driver plus typed document CRUD, identifier inventory, encrypted recovery participation and offline fallback |
duckdb | Bundled DuckDB client plus parameterized, bounded analytics queries |
turso | Direct official Hrana HTTP v3 transport, typed primary CRUD/query facade, parameterized SQL, atomic batches, reversible checksummed migrations, and a persistent SQLite-compatible offline fallback |
surrealdb | SurrealDB HTTP document CRUD, identifier inventory, encrypted recovery participation and bounded read-only ISO GQL; no embedded SDK |
scout-http | Bounded Meilisearch, Elasticsearch and Algolia adapters with deterministic offline fallbacks; Meilisearch also has a live container contract |
pgvector | Typed pgvector SQLx values and parameterized L2/cosine/inner-product helpers; the live contract also selects strict-postgres |
qdrant | Bounded dense-vector collection/upsert/delete/cosine query operations over HTTP with offline fallback |
ai | pgvector plus the generated save_with_embedding for #[orm(embedding_for = "...")] models; the application also depends on rullst-ai |
strict-postgres | The PostgreSQL driver plus concrete PostgreSQL pool, database, query-result, and query paths |
strict-mysql | The MySQL driver plus concrete MySQL paths when PostgreSQL is not also selected |
strict-sqlite | The SQLite driver plus concrete SQLite paths when PostgreSQL and MySQL are not also selected |
The strict backend selection rules and precedence are the same as the umbrella
crate. Each strict-* feature enables its own SQLx driver and selects concrete
public types and query paths; on 13.0.0-alpha.2 and earlier, disable the ORM
defaults to keep the other drivers out of the graph.
The Polyglot features expose capability-specific APIs under
rullst_orm::polyglot; they do not participate in a shared cross-backend
transaction. The base deterministic document store and the MongoDB/SurrealDB
adapters implement DocumentInventory for an application-operated bounded
snapshot/restore contract; it does not supply online isolation or managed
backup. Turso can additionally be selected explicitly by
#[orm(backend = "turso")] and the blank/API scaffold. See the
Polyglot Persistence guide.
rullst-orm-macros
Default features: none. #[derive(Orm)] uses a fail-closed structured parser:
unknown/duplicate options, missing persisted targets, conflicting relations,
unsafe identifiers, and SQLx mappings that generated persistence cannot honor
are compile errors. The exact derive grammar and its raw soft-delete-expression
boundary are defined in the packaged crate README and the
SST.
| Feature | Enables |
|---|---|
runtime-driver-codecs | Enum codecs follow the ORM runtime’s selected SQLx drivers; rullst-orm enables it |
runtime-feature-gates | v13 candidate: Redis and embedding APIs are emitted or omitted at expansion time from the forwarded redis/ai features instead of the application’s own features; rullst-orm enables it |
redis | v13 candidate: forwarded by rullst-orm/redis; emits the Redis cache, hash and event code under runtime-feature-gates |
ai | v13 candidate: forwarded by rullst-orm/ai; emits save_with_embedding under runtime-feature-gates |
strict-postgres | Compatibility marker matching the ORM backend vocabulary; no macro expansion changes |
strict-mysql | Compatibility marker matching the ORM backend vocabulary; no macro expansion changes |
strict-sqlite | Compatibility marker matching the ORM backend vocabulary; no macro expansion changes |
rullst-connect
Default features: none. Provider clients and framework-independent OAuth/OIDC types remain available without a web-framework adapter.
| Feature | Enables |
|---|---|
axum | Axum callback extractors and the local mock IdP router |
actix | Actix Web callback extractors |
leptos | Framework-independent callback extractor module for Leptos integration; no Leptos runtime dependency |
rullst | Convenience integration boundary that enables axum |
retry | Retry-aware HTTP client behavior using reqwest-middleware and reqwest-retry |
reqwest-middleware | The optional middleware dependency alone; prefer retry for retry behavior |
axum-session | Axum plus a ten-minute, one-active-challenge tower-sessions state/PKCE/OIDC-nonce transaction and callback extractor |
sqlite | File-backed shared-local encrypted token snapshots with persisted quota, restart recovery and exact generation compare-and-swap; remote refresh leases, key custody and multi-host operation remain application concerns. v13: also the SQLite login-challenge store over the application’s SqlitePool |
postgres | v13: the PostgreSQL login-challenge store over the application’s PgPool (DELETE … RETURNING consumption) |
mysql | v13: the MySQL/MariaDB login-challenge store over the application’s MySqlPool (SELECT … FOR UPDATE then DELETE in one transaction) |
mock | Deterministic offline provider modules outside test builds |
rullst-messaging
Default features: none. The deterministic process-local broker, versioned envelope, idempotency, consumer groups, leases, retry, dead-letter, and purge contracts are available without optional dependencies. The only remote broker adapter is the v13 Redis Streams candidate (13.0.0-alpha.1 prerelease); other remote brokers are not implemented and have no placeholder features.
| Feature | Enables |
|---|---|
sqlite | Fixed-schema durable local broker with serialized SQLite writes and immutable plaintext or explicit AES-256-GCM content profiles; restart/corruption/rotation/tamper/two-instance evidence is local, while metadata visibility, key custody and remote replication/failover remain explicit boundaries |
orm-outbox | Static bridge from the relational rullst-orm outbox to one configured broker topic, with exact replay after the publish-before-ACK crash window; worker operations and remote atomicity remain application boundaries |
redis-streams | v13 candidate: standalone Redis Streams broker with Rullst-owned fenced delivery indexes; see Redis Streams messaging |
schedules-postgres | v13 candidate: encrypted PostgreSQL recurring-publication outbox and fenced relay into a MessageBroker; enables neither SQLite nor ORM |
webhooks | v13 candidate: sqlite plus one immutable approved HTTPS destination, HMAC-SHA256 signing, bounded retry/dead-letter state, cancellation and terminal retention |
rullst-iot
Default feature: std.
| Feature | Enables |
|---|---|
std | Standard-library support in serialization and Ed25519 dependencies; disabling it makes the crate no_std + alloc |
experimental-simulators | Deterministic MQTT formatting, HSM, and PQC fixtures; not live transports, hardware-backed keys, or production PQC |
rullst-capital
Default feature: axum.
| Feature | Enables |
|---|---|
axum | Axum middleware for the canonical bounded signed-webhook verifier |
actix | Actix Web middleware for the same verifier; it does not enable Axum when selected directly |
quota-sql | Durable idempotent shared quota accounting over SQLite, PostgreSQL, MySQL, and MariaDB; schema setup/migrations and authoritative membership/tier state remain application-owned |
webhook-sql | Bounded durable provider-scoped payload/event claims over SQLite, PostgreSQL, MySQL, and MariaDB, including a caller-owned transaction path; cross-system effects and reconciliation remain application-owned |
invoice-pdf | Bounded paginated A4 invoice PDF with embedded WinAnsi or a validated caller-supplied TTF/OTF; payment/mail orchestration is separate |
rullst-mail
Default features: none. HTTP mail providers remain available without SMTP.
| Feature | Enables |
|---|---|
mail-smtp | Lettre-based SMTP transport |
aws-ses | Official AWS SES v2 SDK, regional SigV4, temporary/rotating credential providers and native attachments/CID; AWS account readiness and inbox delivery remain external |
capital-invoice | Capital’s native invoice PDF plus the final-payment-bound delivery bridge; durable outbox claiming remains application-owned |
sqlite | File-backed shared-local suppression state with exact provider-event replay binding and immutable quotas; webhook authentication, encryption and multi-host replication remain application-owned |
postgres | v13 candidate: namespaced suppression on one authoritative writable PostgreSQL database; independent of SQLite. See shared mail suppression for initialization, runtime grants, retention and pending admission |
rullst-auth
Default features: none.
| Feature | Enables |
|---|---|
oauth | Optional rullst-connect OAuth2/OIDC integration and re-exports |
jwt | Application-issued JWT claims, key rotation, and revocation-store policy |
sqlite | jwt plus bounded file-backed shared JWT revocation and passkey device lifecycle state |
recovery-sqlite | SQLite account registry, recovery and opaque sessions (SqlRecoveryStore); the v13 candidate adds session inventory/logout |
recovery-postgres | The same recovery store on PostgreSQL |
passkey-postgres | v13 candidate: shared account/session-bound PostgreSQL passkey ceremonies |
email-login-sqlite | v13 candidate: recovery-sqlite plus email login over its accounts and sessions; see the email-login contract |
email-login-postgres | v13 candidate: recovery-postgres plus email login |
api-tokens-sqlite | v13 candidate: recovery-sqlite plus scoped API tokens; see the API-token contract |
api-tokens-postgres | v13 candidate: recovery-postgres plus scoped API tokens |
The umbrella crate exposes jwt and sqlite as auth-jwt and auth-sqlite
(auth-sqlite also enables auth-jwt), recovery-* as auth-sessions-*,
passkey-postgres as auth-passkey-postgres, and the email-login and
API-token features as auth-email-login-* and auth-api-tokens-*; each
enables auth. account-mail-* also selects recovery-*. The umbrella does
not forward Auth’s oauth; its own oauth feature adds rullst-connect
directly.
rullst-security
Default features: none.
| Feature | Enables |
|---|---|
redis-rate-limit | Atomic namespaced Redis fixed-window limiter plus its explicit offline mock mode; CI/release run the independent-client contract against a digest-pinned Redis service |
The umbrella crate exposes this as security-redis, which also enables
security.
rullst-ai
Default features: none. Provider clients, prompt inspection and PII masking are always available.
| Feature | Enables |
|---|---|
sql-memory | SqlChatMemory: tenant-aware durable chat memory for SQLite, PostgreSQL, MySQL and MariaDB |
The umbrella crate exposes this as ai-sql-memory, which also enables ai.
Dashboard crates
rullst-nexus enables drivers-all and ai by default, and rullst-studio
enables drivers-all and queue-sqlite. Disable their defaults to select one
backend (and, for Nexus, to leave out the AI assistant and rullst-ai):
| Crate | Feature | Enables |
|---|---|---|
rullst-nexus | drivers-all | All three SQLx drivers in Core and ORM (default) |
rullst-nexus | strict-postgres | PostgreSQL selection in Core and ORM |
rullst-nexus | strict-mysql | MySQL selection in Core and ORM |
rullst-nexus | strict-sqlite | SQLite selection in Core and ORM |
rullst-nexus | ai | The AI assistant at /nexus/chat and rullst-ai (default); the rullst facade enables it only with nexus-ai |
rullst-nexus | tz | v13: NexusTimeZone::named for DateTimeUtc form values in a named IANA time zone (system zoneinfo, bundled jiff database as fallback) |
rullst-studio | drivers-all | All three SQLx drivers in Core and ORM (default) |
rullst-studio | queue-sqlite | Core’s durable SQLite queue backend (default) |
rullst-studio | strict-postgres | PostgreSQL selection in Core and ORM |
rullst-studio | strict-mysql | MySQL selection in Core and ORM |
rullst-studio | strict-sqlite | SQLite selection in Core and ORM |
rullst-studio | remote | The standalone rullst-studio binary’s remote-projects mode (see production monitoring); the binary requires it |
Use the same single-selection rule described for rullst-orm.
v13 prerelease candidate packages
rullst-supervision, rullst-media and rullst-labs are in the v13 release
inventory and were first published in the 13.0.0-alpha.1 prerelease. They have
no umbrella features; depend on them directly. None has default features.
| Crate | Feature | Enables |
|---|---|---|
rullst-supervision | exam | Typed exam sessions, collection categories and browser/capture observations |
rullst-supervision | parental | Course/window parental restriction policies |
rullst-supervision | analysis | exam plus bounded camera-presence/audio-activity adapter contracts |
rullst-supervision | sqlite | exam and parental plus the shared-local SQLite store |
rullst-media | bunny | Bunny Stream adapter, signatures, bounded HTTP and the browser upload module |
rullst-media | sqlite | Shared-local durable assets, leased operations and the application service |
rullst-media | s3 | Experimental S3-compatible object storage (AWS S3, Cloudflare R2, MinIO): presigned upload and playback of the original, no transcoding |
rullst-labs | sqlite | Encrypted shared-local exercise and job storage with leases, cancellation and retention |
rullst-labs | receipt-signing | Receipt signing for the application-owned runner controller |
Packages without optional features
These packages have no public optional Cargo features:
| Package | Always-available scope |
|---|---|
rullst-macros | Core procedural macros |
cargo-rullst | CLI commands, generators, auditing, deployment helpers and the Experimental cargo rullst green; its internal maintainer-tools feature only builds the repository’s sync-badges tool and is not part of the installed CLI |
No optional feature does not mean that a provider is contacted automatically.
External integrations still require explicit runtime configuration and use the
documented deterministic offline behavior for empty or mock_* credentials.
Selection recipes
Minimal HTTP runtime (from 13.0.0-alpha.3 the defaults are empty, so
default-features = false changes nothing there):
rullst = { version = "=13.0.0-alpha.4", default-features = false }
SQLite application with the ORM, every SQLx driver and the SQLite queue, which were the defaults up to 13.0.0-alpha.2 and must be named since alpha.3:
rullst = { version = "=13.0.0-alpha.4", features = ["orm", "drivers-all", "queue-sqlite"] }
PostgreSQL application with explicit domain integrations:
rullst = {
version = "=13.0.0-alpha.4",
default-features = false,
features = ["strict-postgres", "auth", "security", "telemetry"]
}
Embedded IoT model without the standard library:
rullst-iot = { version = "=13.0.0-alpha.4", default-features = false }
Experimental IoT fixtures are deliberately separate:
rullst-iot = {
version = "=13.0.0-alpha.4",
default-features = false,
features = ["experimental-simulators"]
}
Compatibility, MSRV, deprecation, and support policy
This policy applies to the packages in the release inventory:
16 packages in the v12 line and 20 in v13, which adds rullst-privacy,
rullst-supervision, rullst-media and rullst-labs. They are released as one
synchronized release train even when a user depends on only one crate. The
current supported-version table in SECURITY.md remains
authoritative for releases that are actually available; a version in the
workspace is not a supported release merely because its manifest exists.
Source branches
The permanent branches are main (v13 development and its 13.0.0 prereleases),
v12 (frozen; 12.x reached end of life on 16 October 2026) and gh-pages
(generated site/benchmark data).
Use short-lived feature, fix and Dependabot branches with a pull request to the
appropriate protected base. Remove those temporary branches after their changes
are integrated; a superseded PR must identify the replacement preserving its
changes. The former v13 integration branch is retired. Historical releases
remain available through immutable tags and crates.io archives.
Select a published version for application dependencies; a branch name is not a release. Stable and development branches have independent commit histories: ahead/behind counts are not a measure of security, freshness or missing fixes. Do not reset or merge whole release trains merely to make those counters zero.
Carrying fixes between maintained lines
Every stable maintenance fix must be assessed for main. Before closing its
maintenance item, record the linked forward-port PR, the commit where the fix
already exists, or a specific reason it does not apply. Carry the regression
and relevant documentation with the fix, adapt it to the destination API and
run that branch’s required checks before integration. Conversely, evaluate
confirmed v13 fixes for the existing v12 surface without importing new v13
features or breaking the stable API/MSRV.
Versions, release records, feature inventories and branch-specific documentation stay appropriate to each release train. Dependency updates require the same assessment, but resolve and validate each branch’s own graph; do not copy a whole lockfile across diverged workspaces. Prefer a linked, focused port over a blanket merge. A briefly pending port must stay explicitly tracked rather than being hidden by commit counts. This policy does not establish an indefinite LTS commitment or alter the support window below.
Semantic Versioning contract
Rullst follows Cargo Semantic Versioning for stable releases:
- Patch (
x.y.Z) releases contain compatible bug, security, documentation, and dependency fixes. They do not intentionally remove public APIs, public Cargo features, CLI commands, or accepted configuration fields. - Minor (
x.Y.z) releases may add compatible APIs, opt-in features, diagnostics, and deprecations. Changes to defaults or generated projects must include migration notes and compatibility tests. - Major (
X.y.z) releases may contain breaking changes. Each known break must be listed in the changelog and migration guide. - Prereleases such as
13.0.0-alpha.1are public evaluation artifacts. Their APIs may still change in a later alpha, beta or RC, and users must opt in explicitly. Published prerelease archives are immutable, just like stable releases; fixes require a new version.
The compatibility surface includes documented public Rust APIs, public Cargo
feature names, CLI command and flag identifiers, supported configuration keys,
serialized public data contracts, and generator output relied on by the
packaged-distribution tests. Undocumented internals, exact dashboard HTML/CSS,
test fixtures, deterministic mocks, and third-party provider behavior are not
stable interfaces. Items hidden from the API documentation with #[doc(hidden)]
are internals too, even where Rust lets code name them (for example helpers
that generated code calls).
The public API of each library crate, as docs.rs documents it, is recorded in
.github/public-api/; a pull request that changes
it updates that listing, so the change is visible in review.
The cargo-rullst package is the exception. Its library is the CLI’s
implementation, shared by its binaries and tests, and is not a public API: the
stable contract is the CLI’s commands, flags, exit codes and versioned --json
schemas. It has no listing, and the SemVer workflow reports it as excluded
instead of comparing it.
Maturity tiers (v13)
From 13.0.0, each package has a maturity tier. Core and
Extension packages are supported and follow the contract above for the whole
13.x line. Experimental packages, experimental CLI generators and facade
features that re-export an experimental crate are the exception: their public
APIs, features, schemas and generated output may change between 13.x releases.
Every such break is still listed in the changelog and migration guide. Security
handling is the same for every tier.
Minimum Supported Rust Version
The v12 release line and v13 both declare Rust 1.96.0 in every publishable manifest.
- Patch releases do not raise the MSRV.
- A minor release may raise it only with an explicit changelog entry, updated manifests and documentation, and a green MSRV CI job for the release commit.
- A major release may select a new baseline, which must be announced in its migration guide.
The MSRV promise covers the supported feature boundaries exercised by CI. A nightly-only analysis tool or an experimental target does not change the crate MSRV and must be labelled separately.
Deprecation and removal
A stable public API scheduled for removal is marked with #[deprecated] and
kept for at least one released minor version before removal in the next major
release. Documentation must name the replacement when one exists.
An API that is unsound, enables a security bypass, or cannot be made safe may be disabled or removed sooner. Such an exception requires a security advisory or changelog entry describing impact, affected versions, and the supported migration path. Compatibility never overrides safety or fail-closed behavior.
Removing a public Cargo feature is a major change. Adding an opt-in feature is normally minor-compatible. Changing default features is treated as an operationally significant minor change and requires package and generated-app regression tests.
Public dependencies
Some third-party crates are part of Rullst’s public API: their types appear in
public signatures, trait implementations or re-exports, such as
rullst::web::axum and rullst::db::sqlx. For the whole 13.x line, each one
stays on the version line below.
| Crate | 13.x line | Where the API exposes it |
|---|---|---|
actix-web | 4 | Capital and Connect Actix integrations (capital-actix, actix features) |
async-trait | 0.1 | rullst::async_trait |
aws-sdk-sesv2 | 1 | Mail: rullst::mail::aws_ses_sdk and the native AwsSesDriver constructors |
axum | 0.8 | rullst::web::axum, Router, handlers and extractors of Core, Auth, Security, Capital, Connect, Nexus and Studio |
base64 | 0.23 | Connect: From<DecodeError> for ConnectError |
chrono | 0.4 | Core date formatting, rullst::orm::chrono, Mail and Capital |
dashmap | 6 | Security: SecurityStore fields |
http | 1 | rullst::http and the header, method and status types of the web crates |
jsonwebtoken | 11 | Connect: From<jsonwebtoken::errors::Error> for ConnectError |
mongodb | 3 | ORM MongoDbStore (orm-mongodb) |
opentelemetry_sdk | 0.33 | Core DistributedTelemetry::layer (telemetry) |
pgvector | 0.4 | rullst::orm::Vector (orm-pgvector) |
redis | 1 | ORM: From<RedisError> for Error |
reqwest | 0.13 | AI and Connect error variants and conversions |
secrecy | 0.10 | Connect tokens (SecretString, prelude::ExposeSecret) |
serde | 1 | Serialize/Deserialize implementations in every crate |
serde_json | 1 | serde_json::Value arguments and results (Core queue, AI, ORM, Connect, Security, IoT) |
sqlx | 0.9 | rullst::db::sqlx, rullst::orm::sqlx, FromRow, RullstPool and ORM signatures |
sqlx-core | 0.9 | The same SQLx types, through the ORM’s direct dependency |
tokio | 1 | rullst::runtime, rullst::async_runtime::tokio, channel receivers |
tower | 0.5 | rullst::web::tower |
tower-http | 0.7 | rullst::web::tower_http |
tower-layer | 0.3 | Layer bounds of Router and middleware |
tower-service | 0.3 | Service bounds and implementations of middleware |
tower-sessions | 0.15 | Connect: begin_oauth_session and the session extractors |
tracing | 0.1 | Core telemetry (TraceParent) |
tracing-core | 0.1 | Core RedactPersonalDataLayer |
tracing-opentelemetry | 0.34 | Core DistributedTelemetry::layer (telemetry) |
tracing-subscriber | 0.3 | Core RedactPersonalDataLayer implements its Layer |
url | 2 | AI ValidatedEgressUrl::into_url and Connect |
utoipa | 6 | Studio: Studio::with_openapi |
validator | 0.21 | rullst::Validate, ValidatedForm and ValidatedJson |
zeroize | 1 | Zeroizing values returned by Auth, Labs and Security |
Types that one of these crates takes from its own dependencies (for example
axum-core, serde_core, the SQLx drivers, tokio-macros or actix-http)
follow the line of the crate listed.
- A semver-compatible update (a patch, or a compatible minor of a crate at 1.0 or later) may ship in any 13.x release.
- A semver-incompatible update, for example axum 0.8 to 0.9, ships only in a Rullst major release (14.0).
- Security exception. If a vulnerability is fixed only in an incompatible upstream release and the fix cannot be backported, a 13.x minor release may take that update. The CHANGELOG marks it as a security exception, with migration notes.
- Private dependencies, which do not appear in the public API, may change in any release.
- Over time Rullst prefers its own types for the commonly used surface.
rullst::web::axumremains the documented escape hatch to the underlying framework and follows the rules above.
.github/public-dependencies.json
records the same lines. CI fails when a release library’s requirement or locked
version leaves its line; a security exception changes that file and this table
in the same pull request.
Supported release window
Until 13.0.0 is published, only the most recent 13.0.0 prerelease is supported and fixes ship in a new prerelease. From 13.0.0:
- Patch releases (13.0.x, later the patches of the newest 13.x minor) carry bug and security fixes, at any time.
- Minor releases (13.1, 13.2, …) carry backward-compatible features, performance improvements, new opt-in features and compatible dependency updates.
- 14.0 carries only breaking changes.
- 13.x stays the active line for at least 12 months after 13.0.0. Meanwhile
breaking ideas go to the v14 candidates
list, and a 13.x release may mark a future change with
#[deprecated]. - v14 development starts only when breaking changes justify it, and never before that minimum.
- After 14.0.0 is published, 13.x receives security fixes for 12 more months.
There is no LTS or multi-minor backport program: fixes ship in the newest
minor. The exact versions receiving security triage are listed in
SECURITY.md; older versions are end-of-life unless that table explicitly says
otherwise.
Release candidates receive fixes through a subsequent RC number rather than by
overwriting a published artifact. Security reports follow the coordinated
disclosure and response targets in SECURITY.md and the
advisory-exception policy.
Release evidence
Before publishing a stable or prerelease version, the release commit must prove the applicable compatibility contract with:
- synchronized package versions and exact (
=) internal requirements, so Cargo cannot pair packages from different releases; - the MSRV and multi-OS CI jobs;
- full tests, strict Clippy, formatting, docs, and feature-boundary checks;
- packaged crates and generated applications tested without monorepo paths;
- a changelog and migration notes for every intentional behavior change.
Passing these gates is evidence for the exact commit and declared matrix, not a guarantee about untested platforms, external providers, or downstream application code.
Capability status and vision decisions
Rullst’s ambitious ideas are not deleted when an implementation is incomplete. They are kept here with an explicit status and an engineering recommendation. User-facing guides describe what can be used today; this ledger preserves the larger vision without presenting roadmap work as a production capability.
Status is evaluated against the current v13 development source tree (the 13.0.0 prereleases), not against an old changelog entry or marketing description:
- Implemented: a real implementation and focused tests exist; deployment responsibilities may still apply.
- Partial: useful foundations exist, but the advertised end-to-end contract is not complete.
- Experimental: available only behind an explicit experimental/simulator boundary and carries no production guarantee.
- Not implemented: absent or intentionally returns a typed
Unsupportederror instead of simulating success. - Do not promise: the absolute claim is not a technically honest product contract; a narrower engineering goal may still be worthwhile.
The recommendation column is intentionally opinionated. “Worth implementing” does not mean “put it in Core”: several excellent ideas belong in optional, independently tested crates. Ideas that additionally need continuous operations, homologation, or hardware can follow the Maybe SaaS incubation strategy.
Architecture and framework contract
| Capability or former claim | Current status | Recommendation and reason |
|---|---|---|
| Runtime-only Core with optional ORM | Implemented in current hardening (keep validating) | Bare rullst-core now defaults to the HTTP/runtime surface without SQLx/ORM; orm and queue-sqlite are independent opt-ins. Studio and Nexus request their database features explicitly, and since 13.0.0-alpha.3 the application umbrella has no default feature either: orm, a driver profile and queue-sqlite are opt-ins. |
| Lifecycle-aware readiness and graceful request drain | Implemented / bounded per process | ApplicationLifecycle holds monotonic startup/ready/draining/stopped state, at most 32 immutable validated required-component bits, fail-closed request admission and a bounded wait for accepted requests. The lifecycle-aware /ready emits only phase/counts and becomes 503 for startup, dependency failure, lock corruption and drain while liveness remains process-only. Server marks ready after binding, begins drain before Axum’s graceful wait, marks startup failure/termination stopped, and exposes a caller-supplied shutdown future for supervisors/tests. Applications still run timeout-protected dependency checks, establish identity/authorization and operate replica, load-balancer, preStop and termination-deadline behavior. |
| One canonical security stack | Partial (composition risk mitigated; high priority) | Core and the extended crate now reuse one CspNonce, so nested header layers do not invalidate renderer output. Ownership is still split: the Server mounts Core CSRF/WAF/headers/PII while rullst-security supplies explicit RASP/DLP/abuse/telemetry layers. Introduce a Core stack contract implemented by the dedicated crate, then deprecate duplicates after parity tests; reversing the dependency directly would create a cycle. |
| Umbrella features for every advertised crate | Implemented in current hardening (keep tested) | The umbrella exposes optional security, iot, base mail and other domain features, restores the oauth/Connect re-export, keeps SMTP separate through mail-smtp, and has focused facade tests including payment-bound Capital/Mail composition. The extended security suite remains nested under security::runtime because its surface intentionally overlaps the Core baseline; CspNonce itself is already one shared type. |
| Static dispatch everywhere | Partial (do not force absolutely) | Preserve generic/static paths for common cases, but runtime provider registries legitimately need dynamic dispatch. Document each intentional dyn Trait boundary instead of pretending it does not exist. |
| A universal environment/configuration policy | Implemented (keep) | The validated environment model and fail-closed production startup use exact RULLST_ENV → legacy APP_ENV → file precedence. New .env, Kubernetes (make:k8s) and billing scaffolds emit/read the canonical name first; retain the alias for existing applications and keep negative configuration tests. |
| Every source file below 500 lines | Partial (worth continuous refactoring) | Keep 500 lines as a design target, not a release fiction. Split production modules by responsibility; large test fixtures can use a looser limit. |
Uniform #[non_exhaustive], fallible builders, and impl Into<String> | Partial (worth completing incrementally) | Finish this during SemVer-reviewed API work. Mechanical mass changes without compatibility review are not worth the risk. |
| “Zero lock-in” or complete automatic ejection | Do not promise (migration tooling is worthwhile) | Standard Axum/SQLx escape hatches and an inspected eject snapshot reduce coupling, but no full-stack framework can guarantee zero migration cost for every optional subsystem. |
| “Zero panic/crash-free runtime” | Do not promise (the scoped policy is worthwhile) | Enforce typed errors and reject panic!/unwrap/expect in declared production paths. Dependencies, OOM, aborts, and host failures make an absolute runtime guarantee impossible. |
| “Zero latency/zero allocation everywhere” | Do not promise (benchmark instead) | Bounded allocation and latency goals are excellent, but only reproducible per-operation benchmarks should carry numbers. |
| ORM performance versus Diesel and SeaORM | Implemented reproducible bounded harness; no superiority claim | A pinned Criterion target runs five equivalent operations against typed SQLite with one connection per ORM, the same schema/index/100-row seed and identical SQLite policy. CI records Rullst, Diesel and SeaORM under the same commit/runner. The initial local smoke contradicted the historical “negligible overhead versus Diesel” claim, which is therefore rejected rather than marketed away. SQLite microbenchmarks do not establish networked database throughput, concurrency, memory, tail latency or full-application performance. |
“100% memory-safe/no unsafe anywhere” | Do not promise (an unsafe allowlist is worthwhile) | Rust dependencies and the development FFI/hot-swap boundary contain reviewed unsafe. Keep the allowlist tiny, documented with SAFETY, and CI-enforced instead of hiding it. |
| “100% Pure-Rustls for every feature” | Do not promise (a transport inventory is worthwhile) | Prefer Rustls-backed first-party clients, then inspect the complete feature-specific lockfile. Transitive and optional stacks make a universal brand claim brittle. |
| Static competitor matrix declaring other frameworks lack features | Do not maintain without dated sources | Comparative research can be useful, but ecosystems change quickly and absence claims are easy to get wrong. Prefer a Rullst capability/boundary matrix or a dated, sourced benchmark repository. |
| Framework-wide “production-ready” label | Do not promise as one boolean | Publish stability per crate/capability. Routing can be stable while a live payment provider or hardware integration remains unvalidated. |
ORM and persistence
| Capability or former claim | Current status | Recommendation and reason |
|---|---|---|
| SQLx Active Record, transactions, migrations and relations | Implemented / bounded | Keep the PostgreSQL, MySQL/MariaDB and SQLite matrices. Executor-aware generated operations and explicit transaction handles have commit/rollback evidence; typed morph_to plus morph_many/morph_one cover declared SQLx polymorphic targets without a runtime type registry. Caller-owned raw SQL must use the supplied transaction rather than assuming pool calls are scoped. |
| Compile-time query typing | Implemented / bounded typed path | Generated primitive-field filters require the persisted Rust value type and generated column enums reject unknown columns. Dynamic string-column builders, custom conversions and raw SQL intentionally remain runtime checked; do not describe them as SQLx query!-style live-schema verification. |
| Native relational enum mapping | Implemented / bounded per SQLx profile | #[derive(Enum)] owns one validated label mapping for Serde, string/RullstValue conversion and SQLx encode/decode. Blueprint::native_enum creates and exact-drift-checks a named type on strict-postgres, emits inline ENUM on MySQL/MariaDB, and emits TEXT CHECK on SQLite. SQLx Any PostgreSQL is rejected before DDL because it cannot decode custom types. Adding, removing or reordering variants, deployment ordering and PostgreSQL type removal remain reviewed migration work. |
| Fail-closed model tenant scopes | Implemented / bounded | tenant_column now requires a supported persisted field, denies queries/mutations without with_tenant, binds tenant predicates and rejects cross-tenant instance mutations. Keep unscoped() explicit and require application authorization/database RLS where appropriate. |
| Stable large-table chunk traversal | Implemented / bounded | Use fallible chunk_by_id for ascending keyset traversal over generated i32 IDs; deleting processed rows cannot shift later rows behind an offset. chunk remains offset-based, and neither API claims a server cursor, cross-shard snapshot or immunity to unbounded concurrent inserts. |
| Automatic schema synchronization and destructive migration | Partial | The SQLite AST diff is a useful reviewed scaffold. Do not claim universal type-safe synchronization, autonomous DDL, backup or rollback; add backend-specific plans and approval gates before expanding it. |
| Transactional typed partial updates | v13 prerelease / source admitted in PR #236 | The selected-field merge reads the current scoped row, runs the full save lifecycle in a savepoint, and updates the caller object after success. Audits and managed post-commit effects share that boundary. This changes v12 selected-column SQL behavior and does not provide expected-version conflict rejection. |
| Strictly post-commit hooks/search/cache invalidation | Implemented / bounded process-local contract; durable use is explicit | after_commit, generated committed observers, Redis table-cache invalidation/pub-sub and Scout projections run after direct generated or Orm::transaction commit; rollback drops them and PostCommit distinguishes a failed effect after durable persistence. A raw caller-owned SQLx transaction cannot reveal its later commit decision. These hooks are intentionally not converted to durable events without an application-selected schema/payload/policy. |
| Durable relational transactional outbox | Implemented / bounded opt-in primitive | Outbox::enqueue refuses independent commit and writes an idempotent event with the domain transaction; OutboxMigration supplies reviewed schema registration. PostgreSQL, MySQL, MariaDB and SQLite live matrices cover commit, rollback, duplicate reuse, claim and ACK. SQLite regressions additionally cover conflicting key reuse, stream isolation, exact/unexpired claim tokens, concurrent claim exclusion, lease recovery, bounded retry and dead-letter. Delivery is at least once: consumers must be idempotent, and automatic observer/webhook dispatch, tenant authorization, cleanup, cross-store transactions and distributed operations remain application boundaries. |
| Native external search and Qdrant | Implemented / bounded per adapter | scout-http supplies Meilisearch, Elasticsearch and Algolia projection/search adapters; only Meilisearch has a live service lifecycle. Separately, qdrant supplies bounded dense-vector create/upsert/delete/cosine query operations, deterministic fallback, authenticated protocol fixtures and a digest-pinned live lifecycle. Generated Scout projection durability, hosted-provider certification, arbitrary Qdrant filters/vectors and ANN tuning remain explicit. |
| Native Redis data structures | Implemented / bounded Hash, Set and Sorted Set contract | redis supplies an immutable application namespace, validated inputs, bounded reads, TLS-required remote configuration, redacted ACL credentials and deterministic fallback. A pinned live lifecycle proves hash increments, set membership/scan, sorted ranking, exact structure deletion and namespace isolation. Lists, Streams, Pub/Sub durability, cluster/failover, eviction and tenant authorization are not implied. |
| Typed PostgreSQL pgvector queries | Implemented / bounded feature-gated contract | pgvector plus strict-postgres exposes SQLx-compatible Vector and parameterized L2/cosine/inner-product helpers. A digest-pinned live matrix installs the extension, inserts typed values and executes bounded similarity queries. Embedding policy, ANN index tuning, RAG orchestration, authorization and production topology remain application/deployment work. |
| Polyglot MongoDB, DuckDB, Turso and SurrealDB boundary | Implemented / bounded per adapter, with application-operated document recovery | Keep document, OLAP, SQL and graph capabilities separate, with bounds and deterministic offline modes. MongoDB, SurrealDB and the deterministic store now retain IDs through DocumentInventory; an AES-256-GCM envelope binds key ID/application/collection, caps documents/bytes, compares two source observations, resumes only into an exact subset and verifies the final inventory. The live matrix crosses MongoDB→SurrealDB→MongoDB. Applications must quiesce writers and own key/snapshot durability; this is not online snapshot isolation, a universal Active Record facade, managed backup, replication or a cross-store transaction. |
| Full ORM parity for Turso/libSQL | Not planned (deliberate scope) | Turso is a bounded primary database for the Blank and API starters with its own client. Parity would need a second client path beside SQLx for every ORM feature (#[derive(Orm)], Schema, migrations, Studio and Nexus pages), doubling maintenance and the test matrix; SQLx models, Schema and the Studio/Nexus database pages stay SQLx-only. |
| Transparent edge replication and latency guarantees | Not implemented | SQLx read replicas can be configured, but Turso synchronization, health/failover and consistency remain vendor/deployment concerns. Benchmark measured topology; never promise “1 ms” universally. |
| Historical ORM roadmap completeness | Fully classified, not fully implemented | All 45 unique former [x] claims were classified for v12: 28 bounded integral contracts/evidence items and 17 partial. The frozen IDs/states remain in the compact historical ledger, and the full claim text remains in the immutable v12 source snapshot. The comparative benchmark is delivered evidence whose first result refuted, rather than confirmed, the historic performance wording. |
Capital and billing vision
| Capability or former claim | Current status | Recommendation and reason |
|---|---|---|
| Built-in provider set | Narrowed in v13 | Capital keeps its provider-neutral base, Stripe (supported) and InfinitePay (experimental until validated live; live checkout and callbacks fail closed). The Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce and PicPay adapters and the Wise payout adapter were removed to keep the maintained surface reviewable; applications implement other gateways with the provider traits (custom providers). See the migration guide. |
| NFS-e Nacional preparation and issuance | Removed in v13 (possible separate product) | The offline DPS preview and local NFS-e 1.01 preparation were never validated with a real municipality and carried a high maintenance cost. They left the framework in v13; the code remains in Git history and may return as a separate product outside Rullst. Never infer tax authorization from a Rullst receipt. |
| Uniform live support across built-in adapters | Partial (worth a capability matrix) | Publish method-by-method support and provider contract tests. An adapter name must not imply checkout, subscriptions, portal, tax, and webhook support all exist; InfinitePay remains experimental. |
| Shared outbound gateway failure policy | Implemented / bounded | Reviewed live methods use finite connect/request timeouts, disabled redirects and ambient proxies, one-MiB JSON decoding and credential-free HTTPS checkout URL validation. Redacted typed evidence distinguishes permanent, transient and rate-limited failures without automatic mutation retries. Durable provider-forwarded idempotency, backoff/attempt policy, reconciliation, explicit proxy configuration and live acceptance remain application or future adapter work. |
| Axum and Actix signed-webhook middleware | Implemented / bounded, with opt-in shared SQL replay | Both adapters bound the raw body, call one provider-aware verifier before dispatch, restore the exact body, insert a normalized event and enforce the configured replay store. webhook-sql adds immutable bounded shared claims over SQLite/PostgreSQL/MySQL/MariaDB, with restart, contention, drift, capacity and four-protocol live evidence; a stable event claim can share the caller’s relational domain transaction. Middleware payload admission happens before dispatch and therefore is not exactly-once processing. Cross-system effects still require outbox/idempotent-consumer/reconciliation contracts. |
| Subscription handle and grace-period value | Implemented / bounded | SubscriptionHandle<P> validates and redacts its identifier and delegates cancel/pause through an explicit statically dispatched provider (with a global-provider compatibility path). GracePeriod is a fallible half-open Unix-time window capped at 366 days; Billable and its derive can carry a complete optional pair. The application still owns authoritative loading, authorization, trusted time, persistence, entitlement checks, scheduling/retry and provider-specific live behavior. |
Immediate Billable charge | Implemented / bounded for reviewed Stripe Payment Intents | charge_with/charge require integer minor units, normalized currency, explicit provider customer and tokenized payment-method IDs, validated model e-mail and a bounded idempotency key. Stripe forwards that key and confirms off-session; its response must match amount/currency and report succeeded or processing. Exact mock retries are deterministic but carry the distinct non-success Mock status; request/receipt debug views redact identity. Other providers return UnsupportedOperation; mandate/SCA setup, durable idempotency, webhook reconciliation, entitlement changes and live sandbox evidence remain application/deployment work. |
| Stripe metered usage | Implemented / bounded provider-specific API | MeteredBillingProvider uses StripeMeterEvent for the current Stripe Meter Events form contract. It validates positive bounded usage, caps responses, binds provider echoes, redacts identities, forwards a rolling-window identifier and supplies deterministic non-live mocks. Custom adapters without a provider event key report ApplicationOutboxRequired so the application claims its key in a durable outbox. Live-account acceptance, retries, aggregation configuration, reconciliation and entitlements remain external. |
| Coupon application and relative trial extension | Implemented / bounded provider-specific API | CouponCode is bounded/redacted. Stripe’s current expanded-discount update is bound to subscription and coupon; InfinitePay and unreviewed live adapters fail explicitly. TrialExtension makes extend_trial(15) a 1–730-day operation; Stripe protocol fixtures bind the returned subscription/expiration, with an explicit-clock retry path. The host owns authorization, stable command time, serialization, webhook reconciliation, billing-cycle policy and live-account evidence. |
| Shared team/workspace resource quotas | Implemented / bounded, with opt-in durable SQL store | BillingSubject::from_tenant binds shared accounting to trusted tenant context and Billable::quota_request derives the limit from the subscription owner. QuotaGate reserves before executing, suppresses exact replay and compensates ordinary operation failure. quota-sql uses a unique event claim plus conditional counter update; SQLite and live PostgreSQL/MySQL/MariaDB concurrency contracts stop exactly at the shared limit. Applications own membership/authentication, tier persistence and reconciliation, migrations, stale-reservation operations and non-relational adapters. Arbitrary domain writes are atomic only when performed through the same caller-owned SQL transaction. |
| Payment-bound invoice PDF and mail delivery | Partial historical automation; implemented bounded bridge | invoice-pdf validates the compatibility invoice into exact minor units and renders bounded paginated PDF with embedded WinAnsi or a caller-supplied checked font. PaidInvoice requires final Succeeded evidence matching recipient, amount and currency; Mail’s opt-in bridge attaches the PDF, runs pre-flight and sends through the facade or a static driver. Its stable non-secret key is intended for an application-owned outbox. No webhook is inferred, no key is claimed transactionally, provider acceptance/attachment parity vary, and exactly-once delivery is not promised. |
| Durable cross-instance webhook idempotency/replay store | Implemented / bounded relational contract | The opt-in webhook-sql store persists bounded provider-scoped payload digests or stable event keys on SQLite, PostgreSQL, MySQL and MariaDB with immutable capacity/TTL, database-time serialized claims, expiry, restart, contention and fail-closed drift/storage/full-state evidence. Middleware admission is not exactly-once; atomic domain mutation requires the caller-transaction event-key path, and cross-system effects still require an outbox and reconciliation. |
| Static fee, settlement, and tax promises in framework docs | Do not promise | Provider terms and regional availability change. Link to official current terms and document only what the adapter itself implements. |
| “Zero-cost invoicing” | Do not promise | Removing an intermediary fee does not remove certificate, accounting, infrastructure, support, or compliance cost. Preserve cost transparency without advertising zero total cost. |
IoT, edge, and cryptography vision
| Capability or former claim | Current status | Recommendation and reason |
|---|---|---|
no_std telemetry/frame helpers | Implemented (keep focused) | This is a credible small foundation. Preserve the lightweight, transport-neutral core. |
| Ed25519-signed OTA manifest, firmware hash, and monotonic verification gate | Implemented (foundation only) | Keep negative vectors and anti-rollback tests. The gate is not a firmware installer. |
| Persistent anti-rollback counter, download, flash, bootloader slot selection, rollback, and commit | Partial: store contract implemented; platform path absent | The no_std adapter requires exact monotonic compare-and-set and has restart/retry/conflict tests. A concrete store, download/flash path, bootloader coordination and power-loss/fault-injection evidence remain essential before calling OTA end-to-end. |
| MQTT 5, CoAP, and Sparkplug B transport | Packet helpers implemented; transport not implemented (worth a separate transport crate) | The no_std crate now encodes one bounded MQTT 5 PUBLISH packet and RFC 7252 base requests with protocol/boundary vectors. Connections, TLS/DTLS, acknowledgement/retransmission state, broker/LwM2M/Sparkplug semantics and interoperability remain absent; network runtimes should not bloat the frame crate. |
| Hardware HSM/secure-element backends | Not implemented; simulators are experimental (worth adapter traits, later) | Implement only against named hardware/PKCS#11 interfaces with device tests. Never create home-grown “HSM-like” hashing and call it secure. |
| NIST ML-KEM/post-quantum encryption | Not implemented; simulators are experimental (worth later, not home-grown) | Adopt an audited implementation only when a concrete protocol and threat model justify it. A generic “quantum-safe” badge is not worthwhile. |
| CAN/J1939, LoRaWAN, GPIO/I2C hardware integration | Not implemented or helper-only (worth separate hardware packages) | Preserve the idea, but require target boards, interoperability fixtures, and maintainers for each protocol. |
| Embassy async executor integration | Not implemented (worth implementing after the transport split) | Valuable for embedded ergonomics once the no_std ownership and timer/network abstractions are stable. |
| Actual QEMU/hardware-in-the-loop CI | Not implemented (worth implementing with real runtime code) | Merely compiling a target is not QEMU testing. Add it when there is boot/flash behavior to execute and assert. |
| Aerospace/autonomous-vehicle/defense framework claims | Not implemented (do not place in the web-framework Core) | The idea is ambitious, but safety-critical systems need independent standards, certification, hardware, and governance. Consider a separate future project only after the IoT foundation is mature. |
Connect, real-time, queues, storage, and data
The optional v13 distributed tracing candidate links approved operations across processes through explicit trusted parent context and minimized bounded OTLP export. A standard TLS collector and separate Messaging processes passed acceptance; hosted source/package admission passed in PR #236. Final release admission, collector operations and Studio’s separate ingestion/viewer remain distinct responsibilities.
| Capability or former claim | Current status | Recommendation and reason |
|---|---|---|
| OAuth2/OIDC/social-login providers | Implemented (keep as Connect’s current identity) | Continue issuer, redirect, JWKS rotation, offline-fixture, and negative-token contract testing. |
| Signed local OIDC identity-provider fixture | Implemented / bounded for explicit Axum loopback tests | A validated concrete router binds one registered client and callback, keeps authorization codes expiring/one-shot in a 64-record process-local store, verifies optional S256 PKCE, carries nonce into a deterministic-fixture EdDSA ID token, publishes discovery/JWKS and accepts only issued unexpired bearer tokens at userinfo. A real loopback test drives the production OidcProvider through discovery, authorization, token exchange, JWKS verification and replay rejection. The private signing material and credentials are intentionally public/predictable fixtures. It is not safe to expose, has no interactive login/consent, refresh/device/federation lifecycle, durable state, key rotation or OIDC certification. |
| Server-bound OAuth/OIDC callback challenge | Implemented / bounded for Axum + tower-sessions | begin_oauth_session stores state + PKCE and begin_oidc_session additionally stores nonce for ten minutes. AuthSession reads the callback from the query string, removes and immediately saves the one active challenge before constant-time state validation and returns borrowed ExchangeParams; it does not complete Apple’s response_mode=form_post callback, which the v13 AuthSessionForm reads from a bounded form POST body behind a SameSite=None; Secure challenge cookie; sequential replay, expiry, mismatch, replacement and redacted-debug negatives are tested. The host still owns a durable session store, secure cookie/TLS configuration, account linking, recovery and live-provider conformance. One active challenge per browser session deliberately means a second login invalidates the first. The generic store API is not a distributed compare-and-delete, so simultaneous already-loaded callbacks require idempotent effects or an application atomic-challenge adapter. |
| Credential-safe normalized OAuth profile | Implemented / bounded | ConnectUser::universal_profile() emits only normalized identity fields. Serde on ConnectUser omits access and refresh tokens; applications must use a dedicated encrypted store for credential lifecycle rather than serializing the whole response as a session. |
| Automatic OAuth token refresh | Implemented / bounded process-local coordinator plus optional shared-local encrypted state | RefreshableTokenState validates and redacts access/refresh credentials, lifetime, trusted receipt time and provider user identity. AutoRefreshingSession<P> uses static dispatch, a bounded early-refresh window and one async mutex to prevent overlapping refresh calls; callers waiting behind a successful refresh reuse it. The coordinator retains an unrotated refresh token, adopts a valid rotation, binds the returned user and swaps state only after validation. EncryptedTokenSnapshot supplies a bounded versioned AES-256-GCM envelope whose tag binds an explicit rotation key ID, provider and trusted local-account identity. The opt-in SQLite store persists only a pseudonymous binding digest, generation/key metadata and ciphertext under an immutable quota; serialized transactions and exact-successor CAS reject stale local writers, with restart/contention/corruption evidence. The application still owns the remote-provider lease and losing-call reconciliation, key/directory/backup operations, authorization, multi-host replication, retry/backoff, revocation and reauthentication. Providers without refresh fail explicitly. |
| Explicit corporate proxy for OAuth/OIDC | Implemented / bounded HTTP(S) transport | ReqwestClient accepts an authority-only explicit proxy or separately supplied Basic credentials, disables ambient system-proxy lookup and requires HTTPS for authenticated non-loopback endpoints. A loopback protocol fixture proves routing and proxy authorization. PAC/WPAD, SOCKS, proxy mTLS and production network certification remain deployment/roadmap work. |
| Brokered messaging / old Connect Phase 9 | Partial bounded foundation in rullst-messaging | The separate crate now provides a versioned envelope, bounded idempotent publication, groups, leases, retry/DLQ, explicit purge, deterministic in-memory broker, canonical bounded envelope codec, allowlisted W3C trace context and fixed-schema local SQLite. SQLite can immutably select plaintext or AES-256-GCM header/payload protection with row-bound AAD and bounded rotation; raw-state/restart/tamper/row-swap/two-instance tests are executable. An opt-in static ORM relay publishes committed outbox claims with their stable event key and proves exact replay across the publish-before-ACK crash window. Routing/idempotency metadata remains visible, delivery remains at least once, neither relay nor codec creates an atomic remote transport, and replication plus RabbitMQ, Kafka, NATS/JetStream, SQS/SNS, Google Pub/Sub and Pulsar adapters are not implemented. The optional standalone Redis Streams profile passed source admission in PR #236; it owns fenced group indexes and retains its documented deployment and final-release boundaries. |
| WebSocket pub/sub and SSE | Implemented in Core, not Connect (do not duplicate) | Re-export through a coherent facade if desired; one runtime implementation is better than competing copies. |
| Recoverable server-driven Live UI | v13 prerelease / source admitted in PR #236 | live::recovery adds explicit authenticated scopes, mandatory revalidation, full snapshots, atomic expected-revision application contracts and bounded connection/action/timeout behavior. Actual WebSocket and Chromium tests prove conflict, revocation, disconnect and process-restart recovery without replaying uncertain actions. Applications own persistent domain state, transactional permission checks and HTML escaping. Legacy LiveComponent/make:live remain separate; DOM diffing, broadcast and offline mutation queues are outside this profile. See the recovery guide. |
| Bounded Memory/SQLite/Redis queues with recoverable leases | Implemented / bounded | SQLite and Redis additionally persist dispatch_at timestamps for at most 366 days and never claim them early; Redis server-time promotion has a digest-pinned live contract. Execution is poll-dependent and at-least-once. Continue dead-letter observability, graceful shutdown, and multi-worker fault tests. |
| NATS JetStream, SQS/SNS, and GCP Pub/Sub | Not implemented (worth demand-driven optional adapters) | Define one queue conformance suite first; add providers only when each can pass the same delivery/lease/idempotency semantics. |
| Authenticated tenant-local storage namespace | Implemented / bounded in Core | TenantStorage is constructed from a validated TenantContext, prefixes keys with an immutable canonical tenant root and reuses traversal/symlink protections. An exact local test stores the same logical key under two authenticated tenants without interference. Attachment/media integration, remote bucket policy and cross-backend conformance remain open. |
| Authenticated tenant cache namespace | Implemented / bounded in Core | TenantCache is constructed from a validated TenantContext, validates logical keys, applies an immutable namespace for memory or Redis-backed Core caches and deliberately exposes no global flush. Exact same-key non-interference is tested locally. Redis cluster/failover and application-wide conformance remain open. |
| Authenticated tenant realtime namespace | Implemented / bounded in Core | TenantRealtime and TenantPresence bind shared in-process broadcast/presence managers to validated TenantContext, validate logical channel/event/identity names, cap payloads at 64 KiB and prove that the same room name in two tenants does not interfere. Broader room authorization, distributed transport/liveness and other application integrations remain application/roadmap work. |
| S3 and Cloudflare R2 storage | v13 prerelease / source admitted in PR #236 | Optional Core/facade storage-s3 supports bounded private upload/download, metadata, deletion and temporary GET grants with maintained signing and explicit credentials. Disposable-service signature/restart and extracted-consumer checks pass. Owner-account interoperability, multipart streaming, IAM/bucket operations and final release admission remain separate. See private storage. |
| Upload admission and quarantine contract | Implemented / bounded, storage-agnostic | Core validates bounded in-memory bytes against tenant/name/size/type policy, recognized signatures, MIME/extensions and active-text denial (any leading markup after whitespace, byte-order marks or zero-width characters, or javascript:), produces a randomized tenant quarantine key plus SHA-256, and releases only after a clean scanner verdict. The bundled scanner is deterministic and mock-only. Multipart streaming, sandboxed deep parsing/transcoding, S3/R2 movement, archives and a production malware adapter remain open. |
| Image resize pipeline | Not implemented (worth an optional media crate, not Core) | Media decoding expands attack surface and binary size. Isolate it with strict size/pixel limits and fuzzing. |
| Transparent SQLite/Turso/database replication | Not implemented (do not implement generically in Core) | Integrate vendor-specific replication clients or sidecars. A generic timer that prints “syncing” cannot provide consistency semantics. |
| Immutable/zero-copy distributed ledger engine | Not implemented (interesting, but lower priority) | First define the exact consistency, persistence, recovery, and audit use case. The current HMAC audit chain is not a distributed immutable ledger. |
Security, authentication, Studio, and Nexus
| Capability or former claim | Current status | Recommendation and reason |
|---|---|---|
| Versioned AES-256-GCM field encryption | Implemented (keep) | Keep envelope versioning, AAD, random nonces, key rotation, and round-trip/negative tests. Key custody remains external. |
| Canonical browser security baseline | Implemented / bounded in Core | apply_security_baseline is the same composition used by Server: it installs the exact application config outside secure headers/CSP nonce, explicit-origin CORS with separate credential opt-in, WAF, double-submit CSRF and optional PII masking in a stable order. Configuration rejects wildcard/path/query/credential/duplicate CORS origins, malformed CSP/SameSite state and unknown COEP policies. COEP remains require-corp by default; an application can deliberately select validated credentialless or unsafe-none behavior after reviewing its media/isolation boundary. In-process HTTP tests prove matching renderer/header nonce, configured COEP, secure CSRF cookie and write denial/acceptance, preflight allowlist and no grant for a foreign origin. Tune policy per application and test the final browser/proxy/TLS deployment; never promise a universal third-party A+ score. |
| “OWASP A+ guaranteed” | Do not promise | A scanner grade depends on the final page, proxy, cookies, TLS, and deployment. The worthwhile goal is a strict tested baseline, not a badge guarantee. |
| Bounded body-aware WAF/RASP and DLP | Implemented (defense-in-depth) | Keep content-type, streaming, compression, overflow, and header-consistency tests. Never position heuristics as a replacement for parsers, binds, validation, or authorization. |
| Distributed rate limiting | Implemented foundation / external proof pending | rullst-security/redis-rate-limit uses one atomic Redis script, namespace validation, hashed client keys, TTLs and typed fail-closed errors. Empty/mock_* configuration is explicitly process-local and require_distributed() rejects it. Real Redis cross-instance, eviction/failover and trusted-proxy deployment tests remain release work; the legacy no-argument selector stays Unsupported. |
| Deterministic threat assessment and proof of work | Implemented / bounded locally; autonomous AI claim remains partial | Transparent thresholds classify three caller-supplied aggregate patterns and can issue an authenticated, subject-bound, expiring challenge with bounded difficulty/capacity and atomic one-shot verification. Rullst does not collect the aggregates, attribute botnets, block autonomously or share replay state across processes; accessibility, identity, distributed enforcement and DDoS architecture remain host concerns. |
| Route-scoped JSON Schema/OpenAPI body enforcement | Implemented / bounded | JsonSchemaPolicy compiles one JSON Schema 2020-12 document or selected OpenAPI 3.1 component, caps schema bytes/nodes/depth, rejects external references, disables filesystem/network retrieval and uses linear-time regexes. The Axum layer composes exact JSON transport checks and returns a value-free 422 on mismatch. It does not infer schemas from arbitrary routes or replace auth, ownership, domain validation, or query/header/form contracts. |
| Durable tamper-evident audit storage | Partial (worth implementing as a sink interface) | Canonical HMAC sequencing exists. Durable append-only storage, key protection, retention, and independent verification remain application/integration work. |
| Versioned local security-event envelope and spool | Implemented and bounded (v1/local) | LiveSecurityEvent has a frozen six-field v1 contract, a packaged JSON Schema, normalized identifiers/IPs/timestamps, a 2 KiB UTF-8 detail limit, and CEF field escaping. DurableSiemSpool preserves the compatible unsigned SHA-256 format. The opt-in AuthenticatedSiemSpool adds a domain-separated HMAC-SHA256 sequence/predecessor chain, one active plus seven historical named zeroized keys, exact byte/record quotas, restart validation and fail-closed forgery, wrong/missing-key, interior deletion/reordering, symlink, external-change and uncertain-durability behavior. A separately trusted checkpoint is required to detect valid-tail rollback; trusted directory/key custody, permissions, rotation retirement, retention, backup, multi-process exclusion, delivery, retry, acknowledgement, dead-letter handling and external SIEM adapters remain application/operations work. |
| Full normative WebAuthn server | Partial; durable local device lifecycle implemented | The implementation checks the documented ES256/none-attestation ceremony invariants. The opt-in SQLite store adds bounded credential inventory, rename/revocation, restart persistence and atomic counter CAS shared by local processes. Challenge state remains process-local, and an audited library or a full conformance suite is preferable to indefinite custom protocol ownership. |
| TOTP recovery codes | Implemented cryptographic foundation / workflow pending | Security generates subject-bound 80-bit codes, returns plaintext only at enrollment, serializes salted HMAC verifiers, compares in constant time and removes a consumed verifier. The application must encrypt the TOTP secret, persist verifier consumption atomically, rate-limit attempts and own enrollment/recovery UX and audit. |
| TOTP enrollment and Login Jail | Implemented / bounded in-process | MFA secrets contain 160 bits from the OS RNG, verification is constant-time and enrollment can emit a real bounded SVG QR. LoginGuard::record_login_failure_and_wait both records and awaits the progressive tarpit. The host must protect/encrypt the secret and persist/distribute abuse state where required. |
| CLI security evidence tools | Implemented with bounded analyses | SBOM output parses Cargo.lock into CycloneDX 1.5 components with a valid UUID serial number, lockfile checksums and source-aware purls (no license metadata); the doctor parses the real Rust version against MSRV; requested Geiger/SBOM/network checks now fail on findings or incomplete execution, and hooks run all-feature Clippy plus unsafe/IDOR source checks. These are evidence producers, not certification, attestation or static-analysis completeness proofs. |
First-class application JWT service in rullst-auth | Implemented / bounded shared local option | The opt-in jwt feature centralizes issuer/audience, required versioned claims, bounded TTL/scopes, strong HS256 keys, kid rotation and sync/async revocation contracts. Production verification rejects the bundled process-local store. The sqlite profile adds a quota-bound shared local adapter with serialized JTI/session-version mutations, expiry pruning, configuration-drift checks and restart/two-instance evidence. Multi-host replication, database encryption/backup/availability and refresh-token workflows remain deployment/application work. |
| Nexus open-by-default admin | Removed; hardened Nexus is implemented | Keep fail-closed construction, TLS boundary, role/field policy, ownership integration, and rate limits. Production readiness still depends on the host application identity model. |
| Nexus registered-model CRUD and batch actions | Implemented / bounded | #[derive(Nexus)] generates tested metadata for named-field models; primitive widgets are inferred and enum/textarea semantics are explicit. try_build() bounds and validates model, field, primary-key, enum, relation and optional text-tenant metadata. Mutation forms accept at most 256 pairs, bound short/long values to 4/64 KiB, reject unknown/protected fields and parameter pollution, normalize Boolean controls and validate registered enum, JSON, number, date, e-mail and HTTP(S) URL semantics before bound SQL. Sort/search identifiers are registry-allowlisted, batch delete/deactivate is capped at 1,000 selected IDs, and metadata is escaped at HTML boundaries. Tenant-scoped models bind every built-in read/mutation/batch to a trusted Core TenantContext, inject it on create and deny missing context; models without the metadata remain global. with_required_audit couples minimized committed-mutation evidence to the same transaction and fails closed when its fixed schema is unavailable. Deactivation is available only for a writable Boolean is_active/active; external enum reflection, multiline inference, host identity/membership, within-tenant ownership, global/custom-route authorization, database schema/type compatibility and privileges remain host contracts. The same-database audit table is mutable, not append-only/tamper-evident, and host retention/backup/replication/immutable export remain external. |
| Autonomous AI admin that can mutate production data | Not implemented as a safe contract (do not enable by default) | A read-only, explainable assistant is worthwhile. Mutations need explicit human approval, scoped capabilities, audit records, dry-run previews, and rollback. |
| Studio with real telemetry | Implemented with bounded authenticated ingestion and unavailable states | Linux and Windows expose delta-based process CPU alongside RSS, Tokio and local span probes; KPI cards poll the local JSON source. Each separately mounted push-only router binds one producer name/key, accepts only bounded attribute-free v1 spans after exact-body/source HMAC, clock-window and atomic nonce-replay checks, then stores them in bounded process memory. The viewer derives 100 ms slow-label and three-repeat possible-N+1 heuristics without SQL text or bindings. This is not OTLP, durable trace storage, remote Studio access or proof of a defect. The local capability still verifies peer plus local Host, and same-origin Origin on mutations. Queue/revenue/security/AI panels expose only supplied state; unsupported operations, disconnected transports and the absent standalone migration registry fail explicitly. Keep the viewer local by default and never fabricate values for missing sources. |
| Studio data/API/queue/cache/config tools | Implemented with bounded partial surfaces | The SQLx browser uses the application’s pool or the shared Server/Artisan database resolver without a SQLite fallback, reads/filters and, behind the unforgeable verified-local request marker, mutates one primitive non-key value or deletes one complete-PK-selected row with exact confirmation. Tables/columns come from inspected allowlisted schema, values are typed/bound, bodies are capped, views cut cell text to 256 characters and search terms to 256 bytes, a key column outside the identifier boundary makes the table read-only, each write commits in a transaction only when exactly one row changed, and live SQLite/PostgreSQL/MySQL/MariaDB matrices cover the contract. Backend-specific types, tenant/RBAC, audit, rollback and shared production access remain outside it. The read-only API reference requires a supplied OpenApi; SSE records method/URI/status/latency without secret-bearing bodies/headers. SQLite removes successful jobs by default or explicitly retains 1–100,000 for the real queue snapshot with atomic pruning and purge; retained payload access/policy belongs to the host. Redis lists at most 1,000 jobs, retries failed jobs and purges failures and dead letters, and custom queue inspection remains capability-specific. Queue retry/purge and flag toggles require the same verified-local marker as database writes, and the flag page reports a missing table instead of creating it from a GET. An explicitly supplied memory or Redis cache returns metadata only; Studio limits display to 100 opaque HMAC identifiers with size/TTL and permits only individual verified-local invalidation. Values, exact keys and bulk flush remain absent; custom cache drivers fail explicitly until they implement inspection. A successful Studio flag toggle immediately invalidates warm DbFeatureDriver caches in the same process through a constant-size epoch; other processes/direct writers remain TTL-bound without application pub/sub. Relational ER metadata uses parameterized lookups and normalized Mermaid identifiers. Environment values are deny-by-default redacted and typed config omits URLs, paths and secrets. |
| Studio automatically stripped from every release with zero overhead | Do not promise | Feature selection and route mounting determine inclusion. Explicit compile features are clearer than relying on a universal debug/release assumption. |
| Threat Radar with external reputation and verified audit feeds | Partial (worth pluggable connectors) | Local counters exist. External intelligence, durable audit verification, and SIEM delivery should appear only when a source is connected and healthy. |
| “100% OWASP coverage”, “tamper-proof”, or “zero-leak DLP” | Do not promise | These absolutes are not provable framework properties. Preserve the controls and publish exact threat-model/test scope. |
| Automatic SOC 2/ISO/FedRAMP certification | Do not implement as a PASS generator | The evidence exporter is worthwhile; certification evaluates the whole organization and deployment. Report only the outcome of real checks: audit --compliance uses NO FINDINGS, FINDINGS, GENERATED, OBSERVED, NOT CHECKED, ERROR and NOT EVALUATED, never PASS. |
AI and mail
| Capability or former claim | Current status | Recommendation and reason |
|---|---|---|
| DeepSeek provider | Implemented | Keep it in the same provider contract and offline mock suite as the other cloud adapters. |
| Mandatory prompt-injection/PII guardrail pipeline | Implemented in the high-level client and built-in provider transports | The versioned deterministic offline corpus fixes implemented injection/jailbreak/PII regressions across built-in transports. Custom low-level providers remain an explicit extension boundary; heuristics and this corpus cannot prove a prompt safe. AdaptiveAiEvaluator<P> now supplies bounded multi-turn evaluation orchestration, but operators still own the domain corpus and exact live-model execution/review. |
| Adaptive AI evaluation | Implemented / bounded runner | A static-dispatch strategy can use one bounded response to choose its next prompt under turn, prompt/response, deadline and cancellation limits. Results distinguish pass, fail and inconclusive and serialize only low-cardinality metadata without raw prompts/responses/provider errors. Deterministic fixtures prove orchestration, not the safety or quality of a live model; the caller-supplied subject label and scenario assertions require operator governance. |
| Tenant-bound RAG orchestration | Implemented / bounded | RagPipeline::answer composes guarded embedding, a static application retriever, Unicode-safe context budgets, guarded generation, source metadata and mandatory secret-minimized audit in one call. It requires trusted TenantContext, rejects cross-tenant tags and unsafe/empty context, and includes a bounded tenant-partitioned process-local cosine retriever. DurableRagAuditTrail adds a synchronized versioned local file with quotas and restart/corruption checks. The separate AuditDeliveryClient can export a caller-minimized event through a bounded HMAC-authenticated envelope with stable event identity, transient retry, cancellation and a bound acknowledgement. Authoritative datastore authorization, durable/external vector adapters, ingestion/deletion, output policy, receiver/outbox operation and live-model evaluation remain application/deployment work. |
| Authenticated AI audit export | Implemented / bounded transport | Cloud delivery requires HTTPS and literal-loopback HTTP(S) is development-only. HMAC-SHA256 authenticates exact JSON bytes plus key/timestamp metadata; event and acknowledgement sizes, retry attempts and cancellation are bounded, and the ACK must repeat the stable event ID. The caller owns event minimization; the receiver owns freshness/signature verification, deduplication, authorization, persistence, retention, key rotation and SIEM availability. |
| Offline mocks for chat, vision, embeddings, and mail transports | Implemented | Keep mocks deterministic and selected only by explicit empty/mock_* credentials; live endpoints must never silently fall back. |
| Reusable and generated durable chat memory | Implemented / bounded choices | StatefulChat<M> and ChatMemory now live in rullst-ai; the bounded offline store is always available and opt-in sql-memory atomically persists tenant-bound exchanges with revision CAS on SQLite/PostgreSQL/MySQL/MariaDB. Live matrices prove the four protocols. cargo rullst make:chat-session remains an application-owned SQLx/Turso-primary model/migration path. Raw-text encryption, ownership inside a tenant, retention/erasure, provider audit, backups and conflict UX remain host contracts; CAS rejects rather than retries a billable provider request. |
| Native JSON Schema structured output on every LLM | Partial (worth capability-typed support) | Separate parseable JSON from provider-enforced schema. Return UnsupportedCapability when native enforcement is unavailable. |
| Machine-readable AI provider capabilities | Implemented / bounded | AiProvider::capabilities() and AiClient::capabilities() report transport support for text, chat, embeddings, vision, JSON/schema, streaming, tools, timeout, retry, and explicit cancellation. The public matrix states model-dependent and unsupported boundaries. |
| Guarded local AI tool dispatch | Implemented / bounded | ToolRegistry::execute requires an exact allowlist, principal authorization, closed bounded JSON, a call budget and audit sink. Destructive/financial approvals are one-use and payload-bound. DurableToolAuditTrail adds bounded synchronized local persistence with restart/corruption checks; it is not a multi-process writer or authenticated external sink. Provider-native tool calling, approver authentication, domain authorization, rotation/retention and distributed delivery remain application/roadmap boundaries. |
| Strict AI egress fetcher | Implemented / opt-in bounded transport | EgressPolicy::strict() denies all hosts until an exact allowlist is configured. EgressFetcher validates HTTPS URL/port and every DNS answer, pins all accepted addresses in a proxy-free client, verifies the connected peer, revalidates manual redirects and bounds deadline/declared and streamed bytes. It does not wrap arbitrary application/provider clients automatically; tenant-aware destination authorization, response schema/content handling and a deterministic successful live-origin contract remain integration work. |
| OpenAI-compatible local/cloud LLM endpoint | Implemented / bounded, capability-declared | OpenAiCompatibleProvider sends the named chat/embedding/vision/response-format shapes. It defaults to chat-only; optional paths are explicitly declared for the configured model. Unauthenticated HTTP is restricted to a literal loopback IP, cloud requires HTTPS/Bearer, offline mode is explicit, responses/images are bounded, and redirects/environment proxies are disabled. This is not automatic model discovery or arbitrary-HTTP compatibility. |
| “Any local LLM over any HTTP API” | Do not promise | Ollama and the bounded OpenAI-compatible adapter cover two explicit protocols. Arbitrary APIs still have incompatible authentication, streaming, schema, tool, and error semantics; applications implement AiProvider for those contracts. |
| Autonomous AI self-healing/DevOps changes | Not implemented; the CLI assistant was removed in 13.0.0-alpha.4 (do not auto-apply) | cargo rullst ai (with fix and review) is gone; the development error page offers a read-only prompt and a loopback JSON context (/_rullst/errors/{id}) for the developer’s own assistant. Diagnostics and patches can be valuable, but infrastructure/code mutation needs review, capability scopes, preview, audit, and rollback. |
| Tenant-aware secure mail, typed failover, durable scheduling and expiring tracking tokens | Implemented / bounded | The mandatory pipeline and purpose-bound TTL tokens are real. TenantMailResolver accepts the trusted Core TenantContext directly, validates registration, fails closed on unavailable registry state, and proves context isolation with separate drivers. FailoverDriver distinguishes permanent, transient and rate-limited outcomes, sends only eligible failures to another provider, bounds/redacts HTTP error details and emits structured tracing; its state remains process-local. SQLite/Redis queues preserve tenant envelopes and bounded due times without early claims; unsupported real direct transports fail closed, while offline fixtures may retain schedule metadata. Credentials still live in an application-configured in-process registry; encrypted persistence, rotation and cross-process distribution are not implied. Token payloads are HMAC-authenticated but base64-readable rather than encrypted. |
| Attachment inspection, recipient suppression and delivery observations | Implemented / bounded opt-in controls | AttachmentInspectionGuard fails closed before transport using a static scanner contract; its strict local implementation recognizes bounded safe types and rejects executable magic, spoofing, active PDF/SVG, secrets and unsafe links. SuppressionGuard checks process-local or sqlite shared-local manual/bounce/complaint state; SQLite binds verified provider/event payloads, preserves monotonic reasons and enforces exact quotas across restart/two instances. ObservedMailDriver emits only low-cardinality terminal metadata through a non-failing sink. Provider-specific webhook authentication, antivirus/sandbox/CDR, encrypted or multi-host suppression state, external telemetry and alert operations remain host/deployment work. |
| Direct AWS SES v2 delivery | Implemented / bounded protocol contract | Behind aws-ses, the official AWS SES v2 SDK supplies SigV4 and supports temporary or caller-owned rotating credentials, HTML/text, RFC 8058 and attachments/CID. The adapter enforces field/encoded-size bounds and response binding, with a signed regional loopback contract. Offline and explicit bearer-proxy modes remain visibly distinct. Live-account acceptance, verified identities/domains, sandbox exit, IAM, quotas, reputation and inbox delivery remain external. |
| SendGrid, Postmark, Mailjet, Mailtrap and Azure Communication Services transports | Removed in 13.0 (kept in 12.x) | v13 keeps Resend, AWS SES, SendPulse and SMTP. A configured removed driver fails resolution with a typed error instead of falling back; applications implement other providers with MailDriver (Writing your own mail transport). |
| Safe generated mailables | Implemented / bounded scaffold | The five make:mail variants plus make:mail-invoice and make:mail-dunning validate names, reject traversal/collisions, enable the exact facade features, register modules and escape dynamic HTML. The payment-receipt template never claims tax authorization; dunning exposes D+1/D+3/D+7 without inferring billing state. A materialized project passes Clippy and adversarial rendering/link contracts; delivery credentials, scheduling, entitlement mutation and billing policy remain application/external boundaries. |
| Mailgun, Brevo, MailerSend, Plunk, and Scaleway transports | Not implemented (application-owned adapters) | Applications implement MailDriver as in Writing your own mail transport. An official adapter needs a maintainer and the shared offline/live transport conformance suite; provider count alone is not product quality. |
| “Air-gapped/zero-leak AI” | Do not promise automatically | Local endpoints can avoid cloud LLM calls, but host networking, logging, model runtime, and telemetry determine the real data boundary. |
CLI, generated applications, and release engineering
| Capability or former claim | Current status | Recommendation and reason |
|---|---|---|
| Stable blueprint IDs and corrected Island/Resource flags | Implemented in current hardening | Keep snapshot tests because generated CLI identifiers are public compatibility surface. The Blank, API and SaaS IDs are stable; `–blueprint lms |
| Installable application (PWA) | Implemented / bounded; Omni shells removed in 13.0.0-alpha.3 | make:pwa writes a web app manifest, placeholder icons, /sw.js and /offline (src/pwa.rs) and a registration script with an update prompt, wired into the starter router and layouts; the worker runs under the default nonce CSP and never stores non-GET, cross-origin, no-store or private responses. --android writes a Bubblewrap twa-manifest.json, and rullst::pwa::AssetLinks serves validated /.well-known/assetlinks.json. A Node contract executes the worker and registration script, and a generated Blank application is compiled and checked for the routes’ headers. rullst.client v1 supplies bounded typed envelopes/version negotiation shared with Wasm, without client authority. The Tauri-based make:omni/omni commands and the native offline-sync feature were removed; Bubblewrap, PWABuilder, signing keys, store review and device testing remain application work. |
| LMS/Academy starter domain foundation | Removed in v13 | The complete Academy scaffold that generated school-scoped curriculum, publication, roles, completion and certificates was replaced by the smaller LMS starter, which became the examples/lms application in 13.0.0-alpha.4. The design and v12 implementation remain in the git history and the stable v12 branch; recommendation: rebuild it as an application or external example only with a maintainer and tests. |
| Academy activity evaluation | Removed in v13 | The complete Academy scaffold that generated server-authoritative activity evaluation was replaced by the smaller LMS starter, which became the examples/lms application in 13.0.0-alpha.4. The design and v12 implementation remain in the git history and the stable v12 branch; recommendation: rebuild it as an application or external example only with a maintainer and tests. |
| Academy spaced-review scheduling | Removed in v13 | The complete Academy scaffold that generated the durable spaced-review queue was replaced by the smaller LMS starter, which became the examples/lms application in 13.0.0-alpha.4. The design and v12 implementation remain in the git history and the stable v12 branch; recommendation: rebuild it as an application or external example only with a maintainer and tests. |
| LMS example (former starter) | Implemented / bounded | Until 13.0.0-alpha.4 --blueprint lms generated it; it is now the examples/lms application, whose offline tests (cargo test -p rullst-lms-example) run in the workspace suite. It covers auth, catalog, course modules and lessons, an accessible player, enrollment, protected lesson playback and owner-bound monotonic/idempotent progress, with a Nexus admin. v13 replaced the earlier complete Academy scaffold and its --lms-modules profiles with this smaller application; quizzes, schools, roles, scoring, automation and notifications are application work. |
| Academy privacy lifecycle foundation | Removed in v13 | The complete Academy scaffold that generated the school-scoped age-band and retention lifecycle was replaced by the smaller LMS starter, which became the examples/lms application in 13.0.0-alpha.4. The design and v12 implementation remain in the git history and the stable v12 branch; recommendation: rebuild it as an application or external example only with a maintainer and tests. |
| mdBook documentation hub | Implemented in current hardening | Keep mdBook build in CI pages deployment; retired legacy internal SSG in favor of industry standard mdBook. |
Parameterized browser RPC through server_function | Implemented / bounded transport | Concrete async free functions return RpcResult<T> and generate both a matching explicit Axum router and Wasm caller over rullst.client v1. The contract limits owned Serde arguments/results and same-origin paths, caps bodies at 256 KiB, correlates responses, rejects media/version/schema drift, forwards the CSRF cookie in a header and exposes only bounded failure codes. Native HTTP/security tests, compile-fail diagnostics, both Wasm compile targets and generated-project verification are executable. Authentication, authorization, tenant policy, application idempotency, actual browser engines and network/provider operation remain separate evidence; island hydration is not completed by this RPC transport. |
| Every generator/blueprint combination compiled in a tempdir | Implemented for the three starters (six blueprints in v12) | Since 13.0.0-alpha.4 the structural matrix validates 10 shapes of the Blank, API and SaaS starters, four representative projects compile with their generated tests, and seven public-CLI profiles cross the database, AI, Redis and polyglot options; the LMS, Blog, ERP and Portfolio applications are workspace examples. The v12 record follows: a structural matrix validates the 18 public v12 shapes across six blueprints, materializes every blueprint, parses extracted templates, and inventories every public command. Eight representative source-tree projects cover all six blueprints, the fixed Active Record + SSR html!/HTMX profile, API, database, hot reload and release. The former 270-case matrix crossed incomplete frontend/ORM labels and is retained only as prerelease history. On approved candidate a6b3bc8a, the packaged-distribution gate installed the extracted CLI and compiled all six default blueprints without monorepo paths. Expand fmt/smoke only where it adds a distinct contract and do not treat provider/deploy commands as offline tests. |
| Generated Auth/JWT/Billing production defaults | Implemented for the audited regressions; still application code | Async Argon2, issuer/audience/strong-secret checks, authenticated billing identity, CORS allowlists, and signed webhooks are foundations; generated apps still need deployment review. |
| SQLx/Turso billing scaffold | Implemented / bounded Stripe integration (the only generated provider) | make:billing --model and SaaS share reversible persistence, authenticated customer/checkout intents, session IDs, account/test-live scope, signed Checkout/subscription events, current-state reconciliation, revision fencing and atomic event/subscription writes. Open sessions resume without new creation. Materialized SQLite/Turso contracts cover changed email, lost responses, ownership, replay/rollback, concurrent stale reads, replacement subscriptions and restart. Route mounting, provider setup, legacy data migration, retention/entitlement policy and live-account sandbox acceptance remain application/deployment work. |
| Fully compliant automatic OpenAPI | Partial (do not claim completeness) | A syntax-derived draft is useful. Full fidelity needs a typed schema/route contract and validation, not regex confidence. |
| TypeScript SDK that eliminates contract breaks | Partial (worth contract tests) | Generation reduces duplication; add serialization golden tests and API compatibility tests instead of promising elimination of drift. |
| Total framework ejection | Partial (keep as migration aid) | Generate an inspectable entry point, list remaining Rullst dependencies, and run cargo check. Do not promise automatic removal of every subsystem. |
| Opt-in Git pre-commit/commit-message installer | Implemented / bounded (CI remains authoritative) | hook:install writes managed format/Clippy/IDOR and Conventional Commit wrappers, finds repository roots from nested directories, supports linked-worktree common metadata, preserves and chains active hooks instead of overwriting them, stages replacements before activation, and fails with typed errors on missing worktrees or backup conflicts. Temporary-repository regressions prove idempotency, executable permissions, preservation and fail-before-mutation behavior. Client-side hooks remain deliberately bypassable and are not a substitute for protected CI. |
| One-click, zero-downtime deployment | Partial (guided deploy is worthwhile) | cargo rullst deploy writes a Dockerfile, compose.yaml and Caddyfile for a Linux VM with Docker or Podman, and deploy:doctor inspects a configuration snapshot offline, but availability, secrets, migrations, rollback, DNS, and cloud credentials remain operator concerns. |
| IDOR scanner that proves authorization | Heuristic (keep as a warning tool) | AST patterns can find omissions, not prove ownership semantics. Pair findings with route-level negative tests. |
| Compliance report that prints unconditional PASS | Removed; evidence-oriented report implemented | This is the correct direction. Keep raw evidence, tool versions, skipped states, and commit digests. |
| Release packaging in dependency order with preflight gates | Implemented and exercised by stable v12 | Keep package-all-before-publish and topological publishing. The v12.0.0 workflow at eb11f892 admitted the protected exact SHA, reproduced and verified the archives, attested the artifacts, published all 16 packages with short-lived Trusted Publishing credentials, verified registry checksums, and created the GitHub release. The earlier redundant external SLSA job produced no evidence and was removed; no project-wide SLSA level is claimed. |
| SLSA Level 3 certification | Not established (do not claim) | Build provenance is worth keeping. Pursue a named SLSA level only after every requirement is independently evaluated for the actual release platform. |
| RustSec/OSV exception governance | Implemented | Keep only unavoidable exceptions, each with owner, compensating control, and expiry; patched findings must block regressions again. |
| Full Kani/Miri/mutation proof of the framework | Not implemented (do not promise) | Scoped harnesses are valuable. Promote stable, deterministic subsets to blocking gates and label exploratory jobs honestly. |
| “100% test coverage” | Do not promise (measured coverage is worthwhile) | Report the exact commit, features, targets, excluded/generated code, and coverage tool output. Line coverage is not behavioral completeness. |
Suggested sequencing
- Finish 13.0: fixes and documentation only in 13.0.0-rc.1, then the 13.0.0 release evidence (version 12 reached end of life on 16 October 2026).
- Architecture cycle: preserve the new Core/ORM and umbrella feature gates,
consolidate Security, and evolve the bounded
rullst-messagingand remote-storage boundaries through provider-specific conformance evidence. - Demand-backed integrations: durable rate limits/idempotency, S3/R2, and selected message brokers with shared conformance suites.
- High-assurance programs: WebAuthn conformance. Officially homologated NFS-e issuance, if pursued, belongs in a separate product outside Rullst.
- Hardware program: only then expand OTA into real flash/boot lifecycle, MQTT/Embassy, named HSM devices, and audited PQC protocols.
This ordering keeps the extraordinary vision while making the stable surface smaller, testable, and trustworthy.
Rullst v12 Stable Release Record
Note
Historical record (v12). The 12.x line reached end of life on 16 October 2026 with 12.3.0 as its last release. The current line is v13; move a v12 application with Preparing an application for v13.
12.1.2 maintenance release
The v12 maintenance line released 12.1.2 (tag
v12.1.2, 28 September
2026 UTC, 27 September in Brasília) with Nexus stored-value escaping, HTML
macro caller-binding hygiene, explicit OAuth authorization-code response types,
a compatible Tauri family for generated Omni shells and additional Auth, HTTP
lifecycle and SQLite messaging regressions. Compatible corrections are also
prepared for v13 development; its package versions and admission remain
separate.
This main copy does not hold the 12.1.2 publication receipt. See the
12.1.2 review
and maintained release record
for current stable status. The immutable 12.1.1 receipt follows below.
12.1.1 published maintenance release
All sixteen public packages were published as 12.1.1 on September 24, 2026 UTC (September 23 in Brasília). The protected release workflow completed successfully. Independent registry checks confirmed all versions were available, not yanked, and matched the reviewed SHA-256 checksums. The registry observation receipt records those checksums and timestamps; signed release attestations establish provenance.
| Field | Value |
|---|---|
| Git tag and assets | v12.1.1 |
| Source commit | d27db26c6089e06366ca01b67d741f3d037c076a |
| Publication workflow | Successful release run 35925968138 |
| Framework package | rullst 12.1.1 |
| CLI package | cargo-rullst 12.1.1 |
This compatible patch preserves the 12.1.0 API and Rust 1.96.0 MSRV. It fixes application-key/configuration validation, HTTP response-body drain accounting and SQLite lease timestamps under write-lock contention, and includes reviewed dependency, generated-code and documentation maintenance. Applications using public scaffold keys must replace them with unique secure secrets and renew sessions; updating dependencies does not rotate deployed secrets or rewrite application files. See the migration guide.
The exact source passed all 28 required admission workflows. All 40 fuzz targets had eligible original campaign evidence under the reviewed equivalence policy; they were not all rerun for the final documentation change. Release packaging, crates-only consumers, installed CLI blueprints, reproducibility and four native CLI build/smoke jobs passed. Package/evidence digests and all four native artifact inventories matched the cryptographically verified tag/source/workflow/run-bound attestation. Packaged READMEs match the reviewed absolute-link documentation.
Targeted mutations identified additional regression-test work; they do not
establish a full-framework mutation score or absence of defects. Additional
tests follow on maintained v12 and development main, with a new patch version
if another registry publication is needed. No real provider-account acceptance,
physical-device acceptance or broad security certification is implied.
The maintenance and Daybreak review plan retains scope and acceptance boundaries. The published tag, crate archives, attestations and the original 12.1.0 receipts remain immutable.
12.1.0 published maintenance release
All sixteen public packages were published as 12.1.0 on September 20, 2026 UTC (September 19 in Brasília). The protected publication and GitHub release completed successfully; independent registry checks confirmed every version was available, not yanked, and matched the reviewed package SHA-256 checksum. The registry observation receipt retains the sixteen checksums and verification time; release attestations separately establish build provenance.
| Field | Value |
|---|---|
| Git tag and assets | v12.1.0 |
| Source commit | b62390b4fc61e5ac06d24eb31bb7c10d370d679d |
| Publication workflow | Successful release run 35475944059 |
| Framework package | rullst 12.1.0 |
| CLI package | cargo-rullst 12.1.0 |
The release admitted all 28 required workflows for that source, including the
declared fuzz inventory, and produced attested crate archives and native CLI
assets for four targets. The verified Linux binaries reported rullst 12.1.0
through rullst --version, cargo-rullst --version and cargo rullst --version.
The packaged README reflects the reviewed documentation and omits the staging
SaaS URL. These receipts do not establish live acceptance for every external
provider or physical-device compatibility.
The 12.1 migration guide covers application changes when upgrading. Post-publication documentation and informational mutation campaigns do not replace the immutable tag, crate archives or release attestations.
The record below preserves the completed 12.0.0 publication and its original evidence; its commit and checksums do not describe the 12.1 release.
12.0.0 published baseline
Rullst 12.0.0 was published on September 15, 2026. This page is the concise record of that release; it is no longer a development backlog or launch checklist.
Immutable release identity
| Field | Value |
|---|---|
| Version | 12.0.0 |
| Git tag | v12.0.0 |
| Source commit | eb11f892ae28f076e7a83c38a635316c6ed89028 |
| Publication workflow | Successful release run 34955468167 |
| Registry | Rullst packages on crates.io |
| Previous evaluation release | v12.0.0-rc.1 |
The protected release workflow admitted the exact tag commit, reproduced the publication inputs, verified the artifacts, produced GitHub build-provenance attestations, published the packages in dependency order through short-lived Trusted Publishing credentials, and created the GitHub release. A release workflow pass is supply-chain evidence; it is not an independent security, provider, hardware, tax, accessibility, or store certification.
Published packages
All sixteen public packages use the same 12.0.0 version:
- macros:
rullst-macros,rullst-orm-macros; - foundations:
rullst-orm,rullst-core,rullst-messaging; - domain services:
rullst-connect,rullst-iot,rullst-security,rullst-ai,rullst-capital,rullst-mail,rullst-auth; - interfaces:
rullst-nexus,rullst-studio; - entry points:
rullst,cargo-rullst.
The machine-readable publication order is
.github/release-order.json.
What v12 stabilizes
Version 12 stabilizes the bounded public contracts described by the Single Source of Truth. Major delivered areas include the Axum/Tokio runtime and CLI scaffolding, relational ORM foundations, typed Turso support, bounded optional persistence adapters, authentication and security controls, provider-oriented AI, payments and mail, broker-neutral local messaging, Studio and Nexus, and bounded Omni packaging foundations.
“Stable” means the documented v12 APIs and stated boundaries are suitable for normal SemVer maintenance. It does not mean that every roadmap idea is complete or that every third-party service, deployment, mobile device, fiscal authority, or hardware platform has been homologated.
Use these active sources instead of old development percentages:
- Specification — normative architecture and capability boundaries;
- Capability status — compact implemented/partial/ roadmap view;
- Capability ledger — detailed evidence and limitations;
- Release audit — repository-owned verification record;
- Changelog — exact user-facing changes;
- Workflow guide — current automated, scheduled, and manual checks.
Verification summary
The stable candidate passed the required exact-SHA hosted gates and the tag-only publication pipeline. The maintained assurance system covers multi-platform tests, strict linting and formatting, package consumers, documentation, coverage floors, dependency and source policy, code scanning, generated applications, DAST, property tests, sanitizers, Miri, Kani, fuzzing, Omni compile matrices, and release artifact reproduction.
Every result is scoped to its workflow, target inventory, toolchain, commit, fixtures, and environment. For example, compilation of an unsigned mobile shell does not prove physical-device behavior or app-store acceptance, and protocol fixtures do not prove acceptance by a live provider. The release audit records those evidence boundaries.
Upgrading to v12
Existing applications should follow the v12 migration hub and the guide for their starting line:
cargo rullst upgrade --dry-run and cargo rullst upgrade from the v12 CLI
provide an assisted, inspectable migration workflow; the v13 CLI accepts only
v12 and v13 projects. They do not silently rewrite arbitrary application
semantics; review the generated plan, back up application data,
commit a clean baseline, run migrations in a safe environment, and execute the
application’s own tests.
Maintenance after release
The 12.x line accepts compatible correctness, security, documentation, and
dependency-maintenance releases. Breaking APIs and substantial new capabilities
belong to v13. Security reports should follow
SECURITY.md.
12.1.0 historical delivery checkpoints
The following pre-publication checkpoints preserve their original source and test boundaries. References below to a candidate, working source or pending release gates describe those earlier stages. The published maintenance record supersedes their release-readiness status. The maintenance candidate brought together:
- Studio asset/cache routing and Nexus/Portfolio mobile fixes, with migration notes for application-owned workaround routes and generated views;
- reviewed dependency and pinned Action updates, with synchronized fuzz locks;
- SaaS maintenance, including provider configuration, Stripe signature rotation, machine-client WAF defaults and honest operation boundaries across the v12 gateway inventory;
- bounded update notices, explicit advisory
cargo rullst update check, the complete managed-package inventory and staged backup restoration; - the API reference index, clearer HTMX/CSRF guidance and existing verification-efficiency improvements.
The explicit checker has bounded private Unix caching and offline reuse. The
Windows owner/DACL implementation cross-compiles with strict Clippy. Its three
native cache contracts and the complete Windows CLI standard shard passed at
990ddf1cf790cd422c1770a87b3fa19d13ab9ecb in
run 35259362060.
That evidence covers private cache roundtrips/expiry/replacement and rejection
of unsafe ACLs, locks, links and paths; the final candidate still requires all
release gates on its exact source commit.
Its source/age report grants no installation authority. Tauri materializes the
Android Gradle configuration before the standalone Gradle check, and its tutorial
is registered in the documentation inventory gate.
The 2026-09-16 Linux check passed 318 CLI unit/process tests (including upgrade recovery and Omni tool boundaries), both documentation-inventory tests, CLI all-target/all-feature strict Clippy, formatting, Action pin validation and the mdBook build. These targeted local results are not a full workspace, Windows/macOS or native Android sign-off; the candidate’s hosted checks remain required. Native Android evidence must include the actual signed APK and its certificate, not just process fixtures or a successful Rust compile.
The native Android check
passed at 95225f5e: a disposable key signed the generated release APK, and
apksigner verification plus a DER certificate digest comparison passed.
The certificate parser accepts both signer-label formats emitted by the
supported toolchain and rejects substituted certificates or extra signers.
This covers the generated CI shell, not physical devices or store acceptance.
The full Rust CI matrix
passed at 95225f5e, including all test shards on Linux, Windows and macOS,
strict workspace Clippy/format, database/provider matrices and public feature
boundaries. Subsequent checkout, billing, ORM and upgrade changes require a
new exact-commit run; this checkpoint does not certify those later changes or
replace the other publication gates.
The follow-up matrix
at e1d8fe4d finished with 35 successful and seven failed jobs. The failures
came from unconditional enum codecs in isolated ORM builds and an unexpanded
billing persistence placeholder in the CLI template fixture. 98977d0b and
c5ae541d correct those causes with local regression evidence. Later customer
and inbox additions still need a full candidate run; this failed checkpoint is
not release admission.
The sixteen public manifests and their internal minimum requirements now use the unpublished 12.1.0 candidate. The workspace and ten fuzz lockfiles are synchronized without changing external dependency versions. The metadata preflight validates all sixteen packages and their topological order. This version preparation creates neither a release tag nor a registry publication.
Additional local gateway review passes 157 Capital tests with Actix and strict all-target Clippy. Lemon Squeezy now validates subscription lifecycle/state, store/mode/identities and expiry; InfinitePay’s unreviewed live callback fails explicitly. The initial generated-billing containment is superseded by the customer-bound Stripe integration: durable intents, session recovery, atomic event completion and revision-fenced reconciliation. Polar has a typed current product checkout and external-customer webhook binding. The expanded SQLite/Turso materialized contracts cover lost responses, replay, rollback and ownership. All ten scaffold contracts and CLI all-feature/all-target Clippy pass. Existing SaaS controllers require an application-owned migration.
Native artifact preparation at 6db7a2a6 passed actual release builds and
version/inventory smoke checks on Windows x64 and both macOS architectures in
run 35276572044.
Linux built but its staging script failed because the runner’s default Python
lacked tomllib; b97b02ef selects Python explicitly. All four native artifact
builds and version/inventory smoke checks then passed at 020d9a72 in
run 35278808660,
alongside Ubuntu/Windows standard CLI tests and workspace strict Clippy/format.
The full candidate run at 64979b0e exposed macOS cache-writer contention;
explicit lock release and a duplicate-handle regression address it. The next
macOS attempt found a nonportable FIFO test fixture, corrected with POSIX
mkfifo. Native macOS standard CLI tests and workspace strict Clippy/format
passed at 011d9437 in
run 35279884257.
These are bounded checkpoints, not an exact final candidate acceptance.
Working-source update verify authenticates a private local manifest through
a certificate-bound GitHub CLI policy, then hashes both native binaries without
executing or installing them. Local policy, tampering, size, path, timeout and
CLI composition tests pass, together with strict all-target CLI Clippy. The
same certificate policy accepted the already-published v12.0.0 release-context
artifact and rejected changed bytes and a mismatched source tag. That checks
the existing verifier protocol, not unpublished 12.1.0 assets or installation.
Working-source update project prepare now produces private before/candidate
source copies and a bounded migration report without editing the original or
executing builds/tests. Local process fixtures pass for dirty/untracked/deleted
files, ignored/absent lockfiles, virtual workspaces, migration findings,
downgrade rejection and unsafe inputs. Standard CLI tests and workspace strict
Clippy/format passed at 88ddabad on
Windows and
macOS.
The earlier full matrix at 64979b0e finished with 42 successful jobs,
including package-archive consumers and the isolated installed CLI, and only
the subsequently corrected macOS cache contention failure. The full candidate
matrix at 88ddabad subsequently passed, including all native builds,
package-archive consumers and the isolated installed CLI, in
run 35282705151.
It does not cover subsequent updater changes.
Working-source update project verify adds exact command preview, source/plan
revalidation, an exclusive lock and explicit trusted-code consent. Real locked
checks and tests run in another private copy; 14 local preparation/verification
process tests pass, alongside 300 CLI library tests, nine legacy upgrade
fixtures and strict all-target CLI Clippy. Tests include stale inputs, missing
consent, contention, timeout, application-test failure and unexpected source
writes. Reports grant no application authority. Native Linux/macOS verification
passed at 1c4ddd12; Windows passed after path normalization at 1c4b3e60.
The native update diagnostic at 1c4ddd12 passed on Ubuntu but exposed a
Windows lexical mismatch between Cargo paths and private-storage verbatim
prefixes before project execution. Filesystem-resolved membership comparisons
address that defect; Windows confirmation passed in
run 35284872349.
The working source also deduplicates those manifest identities. Explicit review
now revalidates successful command logs and file inventories and returns the
full dependency diff plus its review digest. All 16 local preparation,
verification and review process tests pass with strict CLI Clippy and mdBook;
these were focused review checks before application/recovery was added.
At 9fb49b63, explicit digest-approved application/recovery stages all reviewed
manifest/root-lockfile replacements before original writes, binds access policy,
persists an intent and refuses divergent edits. Local acceptance passed 306 CLI
library tests, 22 project-flow process tests and nine legacy upgrade tests,
strict all-target CLI Clippy and mdBook. Linux/macOS updater checks passed in
run 35287712905;
Windows found an SDDL buffer-capacity versus string-length defect before
original writes; e547746e stops at the bounded terminator. Subsequent Windows
tests exposed Git’s handling of verbatim absolute paths in review diffs;
8b58cd12 uses relative paths from the canonical private cache root.
At that checkpoint, the complete native updater diagnostic passed on
Linux, Windows and macOS together with workspace strict Clippy/format in
run 35289409340.
Additional local tests kill a child after the first production replacement and
recover the persisted before/after states; a Linux file-size limit terminates
real CLI staging with originals unchanged. These tests do not prove power-loss
behavior or replace the remaining native/final-candidate acceptance.
The working source additionally stages bounded authenticated CLI downloads;
it rechecks fresh registry eligibility, authenticates the manifest before fetching
binaries and verifies exact bytes without executing or installing them.
86b6178f adds a total body deadline, including a real slow-response regression.
At f2aa8055, installation review authenticates a fresh eligible candidate and
previews a new/empty private root without creating it or executing binaries.
Local acceptance passed 319 CLI library, 7 discovery, 23 project-flow,
3 artifact/installation and 9 legacy-upgrade tests, strict all-target CLI Clippy
and mdBook. These later changes still require native candidate acceptance.
The latest examples report is pinned to 1af10ed4 and adds application retry
quota/session resumption and Chromium CSP handoff findings. The SaaS generator
now emits an exact Stripe form-action origin. A local Chromium regression
reproduces the strict-default block, accepts the intended POST/303 handoff and
blocks lookalike hosts, another provider, HTTP, another port and an unreviewed
second redirect. Destinations are intercepted; this does not exercise payment,
deployed SaaS or live provider sessions. The 319 CLI library tests, strict CLI
Clippy, format, workflow lint and documentation build pass locally.
The full Rust matrix
passed at 21ce347b, including all three native hosts, generated applications,
strict workspace Clippy/format, MSRV, four native CLI builds, sixteen package
checks and archive-only consumers. The managed installation native check
passed at ef0a8577 on Linux, Windows and macOS with workspace strict Clippy/format.
It covers initial installation, updates, exact predecessor recovery, private
receipts/locks, late replacement failures, an executing older binary and actual
process termination. It does not claim power-loss safety or published 12.1.0
artifact acceptance.
Working source also composes the checked commands through update guided, with
separate default-no approvals and elapsed time per stage. Additional local
regressions resume every injected recovery failure, reject oversized local
files before hashing and preserve installed entries under an OS file-size limit.
A real terminal exercises refused preparation/execution, failed project tests
and approved manifest application followed by recovery. The new guided flow and
additional faults must pass the final candidate’s native matrix.
The Stripe/Polar candidate passed the full 48-job Rust matrix at 4b5aca21,
including native CLI and package-consumer checks. Its source at 8c3c8391 also
passed SemVer, coverage and CodeQL. Links and scope are retained in the
SaaS evidence record.
The subsequent Paddle customer/transaction and subscription work passed fresh
SemVer, coverage and CodeQL checks; the full candidate Rust matrix remains in
progress. See 12.1 migration for final API adoption.
Stripe adapter acceptance now covers test-mode hosted Checkout, paid-invoice
amount/currency/customer checks, provider-relayed signed events, current-state
reads and cancellation at 0f15d70a; see the redacted SaaS evidence record above.
Fresh CLI-generated SQLite billing at 370a4170 also passed controller and
signed-webhook persistence, process restart, duplicate delivery and altered
signature rejection against real Stripe test events. Its isolated database
contained the matching active and canceled subscriptions and event receipts.
Release preparation now includes an exact dated 12.1.0 changelog section,
successful release-note rendering, matching versions and topological internal
dependencies for all sixteen packages, and a crates.io ownership check. None of
the sixteen 12.1.0 versions was indexed during this check. Workflow validation
now extracts notes for the current package version instead of only checking
12.0.0; these checks do not create a tag or authorize registry publication.
The next dependency candidate consolidates Dependabot PRs #207–#212 for 12.1,
including their five Action updates and the four grouped Rust updates. The
tinyvec resolver pin also requires refreshing four ORM-dependent fuzz locks.
Earlier CI results remain evidence for their original commits; the combined
candidate requires fresh CI and exact-main release admission before publication.
Release blockers still open: latest-candidate CI, remaining provider-account acceptance and all exact-main release admission checks. Generated Stripe billing requires the account, recurring price, webhook and migration setup documented in generated BILLING.md. The existing SaaS staging’s 12.0 one-time payment handoff remains separate from the candidate’s isolated acceptance; deployed ingress, authentication and database migration have not been exercised here. The acceptance plan defines the bounded updater delivery. Future v13 migration rules remain v13 work.
Do not regenerate an existing application over local changes. Updating the CLI or framework dependencies neither rewrites application-owned blueprint files nor redeploys the Azure demonstrations. New capability development should focus on v13 after this compatible minor is validated and released; v12 then receives necessary maintenance by exception.
The maintenance candidate also adds default Rullst-branded Omni icons applied after native initialization, application-owned Android release signing and upgrade recovery fixes for macOS root aliases and Windows strict linting. See Android signing. These changes are unreleased; native CI and cross-platform recovery acceptance remain required.
Preserved development history
The former v12 program contained 2,410 lines of planning notes, intermediate
percentages, and a detailed audit of 190 historical roadmap claims. It remains
available in the immutable
v12.0.0 source snapshot
and Git history. The compact classification dataset used by documentation CI is
.github/historical-roadmap-ledger-v12.tsv.
Those historical labels describe what an earlier roadmap claimed and how the v12 audit classified it. They are not a substitute for the current specification, capability ledger, or executable evidence.
Migrating an application to Rullst v12
Note
Historical guide (v12). The 12.x line reached end of life on 16 October 2026. Use these pages only to bring an older application to v12, then follow Preparing an application for v13.
Rullst v12 is a coordinated release train of 16 packages. Upgrade all direct
rullst-* dependencies together; mixing v12 facade/runtime crates with older
domain crates is outside the supported compatibility contract.
Choose the guide matching the application’s source baseline:
Only v5 has a repository release tag among those three baselines. The repository
contains a v6 source snapshot, while “v11” principally identifies ecosystem
dependencies such as rullst-connect rather than a tagged v11 umbrella release.
The guides state these evidence limits instead of inventing release history.
Safe upgrade procedure
- Commit or stash the application and record its current
Cargo.lock. - Back up every database and prove that the backup can be restored.
- Run the old application’s tests and save any known failures.
- Install the published stable CLI with
cargo install cargo-rullst --version 12.1.0 --locked. Pin an exact version for reproducible migrations. - Run
cargo rullst upgrade --dry-runfrom the application root and resolve everyBLOCKER; use--dry-run --jsonwhen CI or other tooling consumes the versioned plan. - Run
cargo rullst upgradeto execute the backed-up transaction. - Review
Cargo.toml,Cargo.lock, every compiler-provided edit, and the Markdown/JSON reports undertarget/rullst-upgrades/. - Apply the baseline-specific manual changes below.
- Run migrations against a disposable copy of production-shaped data.
- Execute the application’s tests, authorization negatives, and deployment smoke tests before merging.
The v12 upgrade command has deliberately bounded behavior:
- it discovers exact Cargo workspace members and updates standard, inline, workspace, target-specific and renamed versioned Rullst dependencies while preserving TOML comments and relative order;
- it leaves unversioned path/git dependencies untouched and reports them;
- it never rewrites valid Axum, SQLx, or Tokio imports;
- it selects source checks from a versioned migration-rule catalog and can emit
the versioned
rullst.upgrade-plan.v1JSON envelope; - it snapshots manifests, the root lockfile and Rust sources before applying
compiler-provided
cargo fix, then runscargo checkfor the workspace’s selected features; - it restores the snapshot when a gate fails unless
--keep-on-failurewas explicitly selected; an interrupted run can be recovered withcargo rullst upgrade --restore <backup-directory>; - it returns failure when any gate fails and never reports “100% stable” or production readiness.
It does not install a new CLI globally, change application secrets, run database migrations, prove runtime behavior, or replace the full test suite.
See the complete assisted upgrade tutorial for the v5 workflow, recovery examples, JSON contract and future-major policy.
Version placeholder
The snippets in these guides target 12.1.0; check publication status.
Keep the selected version while
reproducing this migration, then review any later v12 maintenance update before
changing the application’s lockfile.
Mandatory v12 review
Every baseline must review these contracts:
- Cargo feature defaults and aliases;
- compatibility, MSRV, and support policy;
- explicit Nexus access policy and debug-only loopback convenience;
- explicit Nexus tenant metadata for tenant-owned admin models, with a trusted
TenantContext, plus the optional required audit schema/policy when mutation evidence is needed; - debug-only, loopback-bound Studio deployment;
- ownership checks on parameterized data routes;
- exact CSRF webhook exemptions, WAF/body limits, and trusted proxy identity;
- deterministic offline provider credentials versus live provider validation;
- Capital subscription calls:
Billable::extend_trial(15)is a relative 15-day operation in v12; useset_trial_end(unix_timestamp)for an absolute provider timestamp and persist a command clock withextend_trial_days_atwhen a worker may retry; - the AI provider capability matrix.
Do not deploy v12 solely because cargo check succeeds. Compilation does not
validate data conversion, authorization, provider credentials, reverse-proxy
trust, rollback, or restore behavior.
Migrating from 12.0 to 12.1
Note
Historical guide (v12). The 12.x line reached end of life on 16 October 2026. A 12.x application moves to v13 with Preparing an application for v13.
Version 12.1.1 is published. Updating dependencies or the CLI does not rewrite generated application files, migrate databases or redeploy the Azure examples. Keep the existing application and lockfile, generate a separate comparison project, and review the changes below.
Upgrading to the 12.1.1 patch
The 12.1.1 maintenance change set preserves the 12.1.0 API, database schema and Rust 1.96.0 MSRV. Applications using public scaffold keys must replace them with unique secure secrets; changing a session-encryption key requires session renewal. HTTP drain now accounts for unfinished response bodies, and SQLite message leases use the time after acquiring the write lock. Existing generated LMS files are not rewritten by a dependency update.
Account mail and machine requests
The account-mail guide describes the opt-in durable PostgreSQL/SQLite recovery registry and worker. Existing reset templates benefit from the mandatory-pipeline fix, but existing encrypted-cookie sessions and password tables do not gain revocation automatically. Review imports before switching the authoritative account store. Keep provider tracking disabled.
Server::with_machine_endpoints accepts exact method/path registrations with
mandatory bearer, signed-webhook or transport mTLS verification. Use it for
trusted machine POST routes instead of disabling browser CSRF globally. It
rejects browser-cookie/origin inputs; the application still owns domain
permissions and ingress rate limits.
Core with orm and disabled defaults now requires an explicit strict-*
backend or drivers-all. Studio and Nexus retain their default convenience
features; disable defaults to obtain an exclusive backend graph. Studio’s
SQLite queue is controlled by queue-sqlite, independent of PostgreSQL/MySQL.
The facade forwards these choices, including optional Studio/Nexus consumers.
Billing migration
Generated real-mode Stripe billing now additionally requires
BILLING_LIVE_ACKNOWLEDGEMENT=I_UNDERSTAND_REAL_CHARGES. This is a launch control,
not a substitute for merchant activation and refund/dispute procedures. Test
credentials do not need that acknowledgement.
For a one-time purchase, use the opt-in StripePaymentCheckoutRequest and
create_one_time_checkout API, with StripeOneTimePrice from a server-owned
allowlist. It validates the current active provider price before creation and
uses mode=payment, one card-funded item and a fixed amount/currency. It does
not enable adaptive pricing, discounts or automatic tax. The existing generated
subscription flow remains recurring; do not substitute a one-time price there.
Persist owner/account/mode/attempt/session bindings. Process
verify_one_time_event notifications through a durable inbox, then re-read
read_one_time_receipt before an atomic entitlement transition. Refund and
dispute hints require reconciliation too. Any partial refund/dispute prevents a
Paid receipt; restoration after a resolved dispute needs an explicit reviewed
application policy. Never grant access from a return URL or mock receipt.
This additional one-time contract has protocol tests; the earlier subscription
sandbox evidence does not validate it or the production certificate offer.
| Existing integration | Required change |
|---|---|
Generated SaaS/make:billing on 12.0 | Adopt the new billing modules and migrations in a reviewed application change. Configure the provider account, mode, recurring price allowlist and HTTPS URLs from generated BILLING.md. |
| Stripe email-based owner lookup | Persist opaque local owner/customer/account/mode bindings. Use typed customer and checkout requests with durable immutable attempt keys; store session IDs before HTTP 303. |
| Stripe normalized events alone | Adopt signed Checkout/subscription envelopes, current-state reads and atomic inbox/domain commits under database revision fencing. Never grant access from email or a return-page visit. |
| Stripe portal by email | Use the typed customer-ID-bound portal operation after authorizing the persisted binding. Other legacy uniform live portal calls remain unsupported. |
| Paddle legacy checkout | Migrate to PaddleCustomerRequest, PaddleCheckoutRequest and create_transaction_checkout. Configure the default approved Paddle.js payment page, select sandbox explicitly and persist all attempt/customer/transaction bindings. |
| Polar price-based checkout | Migrate to PolarCheckoutRequest and create_product_checkout with a product UUID and opaque external customer identity. Supply client IP only from a reviewed trusted-proxy boundary. |
| Lemon Squeezy checkout | Supply the merchant’s positive numeric store ID through with_store_id and a variant belonging to that store. |
| Wise email-based transfer | Do not invoke it with real credentials. Recipient, quote, transfer and funding need separate reviewed contracts; the legacy call fails before network dispatch. |
Stripe’s customer-ID portal requires an active default portal configuration in
the selected account and test/live mode, plus permission to create portal
sessions. Configure allowed subscription changes and cancellation explicitly and
test the portal handoff separately. Successful Checkout acceptance does not
validate portal configuration; generated BILLING.md records this prerequisite.
The new generated durable real-provider integration is Stripe-specific. Paddle
and Polar expose typed adapter operations; their application persistence and
event orchestration remain explicit. Consult the
complete provider matrix
before enabling a method. Empty/mock_* credentials are deterministic local
fixtures; mixed configuration must not silently produce a real-payment success.
Do not replay a Paddle or Polar creation merely because it timed out. Their
typed correlation metadata does not establish provider idempotency. Recover
known objects and reconcile uncertain outcomes before another mutation. Stripe
also needs durable attempt retention and reconciliation after its idempotency
window. Generated BILLING.md describes recovery and replacement subscriptions.
Existing customer rows cannot be adopted just by matching email. Back up the application database, review schema/unique constraints and migrate only bindings established from authorized provider evidence. Do not replace paid production tables with empty generated models. Test rollback and restart before rollout.
Application and CLI changes
- Replace
Redirect::temporary/307 for hosted checkout with a 303 handoff. Add the exact provider checkout origin to application CSPform-action; Stripe’s default SaaS policy permitshttps://checkout.stripe.com. - Commit application
Cargo.lockand use locked container builds. Remove the generated unsupported MSVC/DEBUG:FASTLINKflag from existing projects. - Review strict ORM feature edges: backend-specific builds require disabled default features throughout the resolved dependency graph, not just one crate.
- Nexus and AI share provider resolution, including explicitly configured Groq. Azure Basic authentication still requires the explicit trusted-TLS capability; raw forwarding headers do not establish trusted termination.
- Core’s default WAF no longer blocks ordinary HTTP libraries by user agent. Explicit application blocklists remain unchanged and may need local review.
- Remove application workarounds for Studio embedded assets/cache navigation and the Nexus mobile drawer only after testing the updated native renderers.
- Native CLI installation/update requires the admitted release artifacts and their verification evidence. Candidate CI binaries are not release assets. Application updates require a reviewed diff; installing the CLI alone does not update an application’s dependencies or migrations.
Acceptance and release evidence
Run the provider’s sandbox journey against the candidate, including signed events, owner isolation, retry/replay, cancellation and current-state recovery. Keep fixture tests, hosted CI and provider-account acceptance distinct. A Chromium navigation to hosted Checkout proves handoff only; it does not prove payment, webhook delivery or subscription reconciliation. A one-time payment application does not validate the framework’s recurring subscription flow.
The release additionally requires the full workspace all-feature tests, strict
Clippy, formatting, package/consumer checks, coverage, SemVer/security checks and
every workflow listed in .github/release-required-workflows.json at the exact
v12 commit. Only then may the protected release pipeline publish the sixteen
crates in .github/release-order.json and attach verified native CLI binaries.
See release recovery for partial publication handling.
Migration guide: v5 to v12
Note
Historical guide (v12). The 12.x line reached end of life on 16 October 2026. The v13 CLI upgrades only v12 and v13 projects: bring the application to v12 with this guide and the v12 CLI, then follow Preparing an application for v13.
Baseline: the repository tag v5.0.0 and the compatible v5 line. This is a
multi-major migration. Use a branch and follow the common
safe upgrade procedure before applying these changes.
The assisted upgrade tutorial
shows the complete CLI transaction and recovery flow.
1. Replace the dependency graph
The v5 facade had no default features, kept several modules inside rullst,
depended on rullst-orm = 6.1.1, and used rullst-connect = 11. V12 releases
all framework packages in one version train and enables orm plus
queue-sqlite by default.
To preserve the old opt-in behavior, start explicitly:
[dependencies]
rullst = {
version = "12.1.0",
default-features = false,
features = ["orm", "queue-sqlite"]
}
Add only the domain features the application actually uses. Consult the
feature matrix; do not copy --all-features into a
production manifest. Remove independently pinned old Rullst package versions or
move every direct package to the same v12 version.
Important feature changes include:
auth,ai,capital,nexus,studio,security, andiotnow select real optional crates rather than empty facade markers;mailselects HTTP/offline transports without SMTP;mailerremains a compatibility alias formail-smtpwhen Lettre SMTP is required;redisselects both queue and cache Redis boundaries;strict-postgres,strict-mysql, andstrict-sqliteselect concrete ORM database types. Select at most one in an application.
2. Move from attribute-style routing
The v5 README demonstrated attribute-style #[routes] registration. V12’s
supported registration surface is the explicit routes! macro. The deprecated
#[route] compatibility marker does not register a route.
use rullst::{Server, response::Html, routes};
async fn home() -> Html<&'static str> {
Html("Hello from v12")
}
#[rullst::runtime::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app = routes![get("/" => home)];
Server::new(app).run(3000).await?;
Ok(())
}
Direct Axum escape hatches remain supported through rullst::web::axum or a
normal direct dependency. The upgrade command does not rewrite those imports;
its versioned scanner reports the known v5 routing/server markers for manual,
semantic conversion.
3. Review ORM and migrations
- Replace the old independently pinned ORM with v12.
- Decide between SQLx
Anyand one concretestrict-*backend. - Run schema migrations on a disposable restored database first.
- Recheck raw SQL parameterization, tenant predicates, ownership guards, query limits, pool timeouts, and rollback behavior.
- Never assume that a successful compile proves an old migration is reversible.
4. Rebuild administrative and security boundaries
Nexus no longer mounts as an implicitly open admin router. New code should use
Nexus::try_build() with either validated production authentication or
LocalNexusAccess::loopback_only() in a debug build. Basic Auth requires a
verified TLS boundary in production.
Tenant-owned Nexus models should add an explicit text tenant column to their
derive metadata and install TenantContext only after authenticated membership
resolution. Models without this metadata remain global administrator models.
If with_required_audit() is enabled, run
create_nexus_audit_table() as a deployment migration first; a missing table
intentionally rolls mutations back.
Studio must remain a separate debug-only loopback service. Rebuild the server
middleware order using the v12 production baseline, then add application
authorization and the extended rullst-security layers explicitly where used.
Re-test CSRF, CORS, webhook signatures, request limits, IDOR, and cross-tenant
denials.
5. Validate provider migrations
Empty or mock_* credentials intentionally use deterministic offline behavior.
They are not proof that mail, OAuth, billing, AI, or storage providers work
live. Validate each configured provider in a non-production account and keep
unsupported fiscal, transport, and hardware capabilities fail-closed.
Completion gate
Run at minimum:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
Also test the application’s actual feature selection without --all-features,
database restore/migration/rollback, Nexus and Studio network exposure, and a
production-profile smoke build.
Migration guide: v6 to v12
Note
Historical guide (v12). The 12.x line reached end of life on 16 October 2026. The v13 CLI upgrades only v12 and v13 projects: bring the application to v12 with this guide and the v12 CLI, then follow Preparing an application for v13.
Baseline: repository source commit 5229132f, which bumped the facade to
6.0.0. There is no repository v6 tag, so first verify the exact crate versions
in the application’s Cargo.lock; do not assume this snapshot matches every
artifact an application may have consumed.
Follow the common safe upgrade procedure.
1. Normalize the mixed-version ecosystem
The v6 snapshot combined rullst = 6.0.0, rullst-orm = 6.1.1,
rullst-connect = 11.0.0, path-based internal crates, and unversioned internal
path requirements. V12 uses one synchronized version for all 16 publishable
packages.
For the old no-default behavior:
[dependencies]
rullst = {
version = "12.1.0",
default-features = false,
features = ["orm", "queue-sqlite"]
}
The explicit features above opt into v12’s local database behavior. Omit them
for a database-free HTTP service. If the application relies on v12 defaults,
rullst = "12.1.0" enables both automatically.
Remove obsolete direct lettre wiring from the facade migration and use
mail-smtp. Review the new security, iot, redis, and strict-* boundaries
in the feature matrix. Path-only dependencies are not
changed by cargo rullst upgrade.
2. Preserve valid ecosystem escape hatches
V6 applications often imported Axum, SQLx, and Tokio directly. Those imports
remain valid application choices. V12 exposes convenience paths, but migration
does not require global substitutions such as axum:: to rullst::server::.
Review imports using compiler errors and API intent, not blind text replacement.
Prefer explicit routes! registration for the central router and propagate
startup errors from Server::run. Deprecated route attributes must not be
treated as functional registration.
3. Revalidate modular crate boundaries
V6 had already started splitting Core, Auth, Mail, AI, Nexus, Capital, and Studio. V12 makes those package and feature relationships explicit and adds the independently versioned ORM macros, Connect, Security, and IoT packages to the same release train.
- Update direct imports only when the corresponding feature is enabled.
- Remove code that depended on empty facade features compiling without their domain crate.
- Use the umbrella
mailfeature for HTTP/offline transports. Addmail-smtponly when the SMTP dependency is required. - For OpenTelemetry export, enable
telemetry; process-local Radar data does not require it.
4. Apply v12 security changes
- Build Nexus with an explicit access policy and
try_build(). - Keep Studio debug-only and loopback-bound.
- Derive tenant identity from authenticated membership, never directly from a selector header, subdomain, or URL parameter.
- Recheck exact CSRF exemptions, trusted proxy/TLS metadata, webhook replay protection, body limits, and parameterized-route ownership.
- Treat local security telemetry events as unsigned unless a configured audit verifier proves HMAC integrity.
5. Revalidate data and providers
Choose one strict database backend or SQLx Any, run migrations on restored
data, and exercise real SQLite/PostgreSQL/MySQL behavior used by the application.
Then validate live OAuth, mail, billing, and AI credentials separately from
their deterministic offline modes.
Finish with the full commands in the common guide, the application’s exact feature build, a production-profile smoke test, and a documented rollback rehearsal.
Migration guide: v11-era dependencies to v12
Note
Historical guide (v12). The 12.x line reached end of life on 16 October 2026. The v13 CLI upgrades only v12 and v13 projects: bring the application to v12 with this guide and the v12 CLI, then follow Preparing an application for v13.
There is no repository tag for a rullst umbrella v11 release. The v5/v6 source
line did, however, consume ecosystem packages such as rullst-connect = 11.
This guide therefore targets applications whose manifests or lockfiles contain
v11 Rullst ecosystem crates, and any untagged development snapshot described as
“v11”. Record the exact starting commit and dependency graph before proceeding.
Follow the common safe upgrade procedure.
1. Inventory before editing
cargo tree -e features
cargo metadata --format-version 1
Save the output and list every direct or transitive rullst-* package. In v12,
all 16 published packages must use the same release version. Do not update only
rullst-connect or only the umbrella crate.
Example explicit selection:
[dependencies]
rullst = {
version = "12.1.0",
default-features = false,
features = ["orm", "queue-sqlite", "auth", "oauth", "security"]
}
If the project uses renamed dependencies or local paths, update those entries manually; the upgrade command intentionally changes only standard versioned Rullst dependency keys.
2. Connect and authentication
V12 rullst-connect keeps provider-neutral OAuth/OIDC types available without a
web adapter. Select the adapter explicitly:
axumfor Axum extractors and the local mock IdP router;actixfor Actix extractors;axum-sessionfor Axum plustower-sessionsintegration;retryonly when the Connect retry client is intended;mockonly for deterministic offline provider modules.
Empty credentials choose explicit offline behavior. Custom-provider configuration errors and live callback/state validation remain fallible. Re-test PKCE, state, redirect URL allowlists, session binding, logout, refresh, and unknown-key JWKS refresh behavior.
rullst-auth enables Connect integration through its oauth feature, while the
umbrella rullst facade exposes Connect through its own oauth feature. Review
which layer the application imports instead of enabling both reflexively.
3. Adopt the v12 runtime contracts
- Use explicit
routes!registration; compatibility route attributes do not perform runtime registration. - Propagate startup, pool, migration, provider, and server errors.
- Review the changed default features (
ormandqueue-sqlite). - Select at most one
strict-*database backend. - Build Nexus with an explicit access policy and keep Studio on debug loopback.
- Mark tenant-owned Nexus models with an explicit text tenant column and supply
a membership-derived
TenantContext; install the audit table before enablingwith_required_audit(). - Re-run IDOR, tenant-isolation, CSRF/CORS, webhook, and trusted-proxy negative tests.
4. Review AI and tool assumptions
V12 has a machine-readable provider capability matrix.
Built-in live transports have configurable request deadlines. The separate
StreamingAiClient enforces bounded output and explicit cancellation, and an
exact OpenAI-compatible configuration may opt into strict incremental SSE.
Provider-native tool calling, automatic provider retries, ordinary
non-streaming cancellation and streaming for other provider protocols are not
uniform transport capabilities. ToolRegistry is guarded local dispatch
infrastructure, not an authorized autonomous-agent boundary.
Completion gate
Compile both the application’s exact feature set and the intended release profile. Run the full local trifecta, provider integration tests in non-production accounts, database restore/migration/rollback, and network-exposure checks. Keep the previous artifact and database restore procedure available until the v12 deployment has passed its observation window.
AI provider capability matrix
This matrix describes the transport paths implemented by the current
rullst-ai source. Anthropic and Gemini streaming (with its explicit
cancellation) and token usage were added in v13; the v12 crate does not have
them. The matrix is not a claim that every model sold by a
provider accepts every request. Model availability, account entitlements,
regions, quotas, and upstream API behavior remain provider concerns.
Applications can inspect the same contract in code through
AiProvider::capabilities() or AiClient::capabilities(). Built-in provider
tests assert every row so unsupported paths remain explicit instead of silently
falling back to another operation.
| Provider transport | Text | Chat | Embeddings | Vision | JSON | JSON Schema | Streaming | Provider tools | Rullst timeout | Automatic retry | Explicit cancellation |
|---|---|---|---|---|---|---|---|---|---|---|---|
| OpenAI | yes | yes | yes | yes | native mode | yes | no | no | yes | no | no |
| Anthropic | yes | yes | no | yes | prompt only | no | native SSE | no | yes | no | yes (stream) |
| Gemini | yes | yes | yes | yes | native mode | yes | native SSE | no | yes | no | yes (stream) |
| DeepSeek | yes | yes | no | no | native mode | default model only | no | no | yes | no | no |
| Ollama | yes | yes | yes | yes | native mode | yes | no | no | yes | no | no |
| OpenAI-compatible | yes | yes | declared | declared | declared native mode | declared | declared SSE | no | yes | no | declared for SSE |
yes means Rullst constructs and parses that provider request. A configured
model can still reject vision, embeddings, or schema output. In particular:
- DeepSeek JSON Schema is enabled only for the default
deepseek-v4-flashtransport contract. Selecting another model makes the capability false and returnsUnsupportedCapability, including in deterministic offline mode. - Ollama vision, embeddings, JSON, and schema support depend on the installed local models. The transport can send those request shapes; it cannot prove that an arbitrary model implements them.
- Anthropic JSON uses an instruction requesting one JSON value. It is labelled
prompt onlybecause the current transport does not request native JSON mode or schema enforcement. - Empty and
mock_*credentials select deterministic offline behavior. They do not contact the configured endpoint and do not promote capabilities that the transport marks unsupported. - The OpenAI-compatible adapter defaults to text/chat only. Its exact endpoint/model configuration must explicitly declare embeddings, vision, native JSON mode, JSON Schema, and SSE streaming. This reports which request shapes Rullst will send; it does not discover or certify model behavior.
Vision input sources
The Vision column describes provider request-shape support, not unrestricted
image acquisition. AiClient::prompt_with_image accepts application-admitted
bytes. prompt_with_image_file additionally requires an exact canonical
LocalImagePolicy root and byte budget. prompt_with_image_url accepts only an
explicit EgressFetcher, so HTTPS host allowlisting, DNS pinning, peer and
redirect validation, proxy bypass, timeout and streaming limits are mandatory.
Both helpers verify bounded JPEG/PNG/WebP/GIF signatures; a supplied remote
media type must match the bytes. Capability and prompt checks happen before
file or network I/O. Local-directory trust, tenant/owner authorization, image
decoding safety and model behavior remain application/provider boundaries.
OpenAI-compatible local and cloud endpoints
OpenAiCompatibleProvider::try_local accepts an unauthenticated OpenAI-shaped
base URL only when its host is a literal loopback IP such as 127.0.0.1 or
::1. It may use HTTP for local development and never probes localhost
implicitly; requiring an IP literal avoids trusting host-name resolution.
try_local_with_bearer supports an explicitly authenticated loopback server.
try_cloud requires HTTPS and Bearer authentication; empty and mock_* keys
select the offline fixture. All constructors reject URL credentials, query
strings, and fragments, disable redirects and environment proxies, cap images
at 10 MiB and JSON responses at 2 MiB, and retain the ordinary 30-second
configurable request deadline.
This adapter covers /chat/completions, optional /embeddings, OpenAI-shaped
image content, the declared response-format modes, and opt-in strict
text/event-stream chat deltas. It does not claim Azure query/header
conventions, arbitrary authentication schemes, provider-native tools, retries,
automatic model discovery, or compatibility with an
unrelated HTTP protocol. Implement AiProvider for those explicit semantics.
Local runtimes such as llama.cpp server, LocalAI, LM Studio, and vLLM are
possible consumers only when their installed configuration exposes these exact
shapes; Rullst does not certify a product name or infer capabilities from it.
Operational boundaries
Streaming
StreamingAiClient<P> applies provider-independent output limits through
static dispatch. For an exact OpenAI-compatible configuration that declares
with_streaming(), Rullst parses incremental UTF-8 SSE deltas, requires the
terminal [DONE] marker, rejects an incorrect media type, malformed/truncated
events, a finish_reason of length or content_filter (the reply was cut
short or withheld) and all configured byte/chunk overflows. The maximums are 4,096 chunks,
64 KiB per chunk and 2 MiB of raw response and delivered text.
Anthropic and Gemini implement their native SSE protocols (v13) with the same
bounds, guardrails and cancellation. Anthropic reads message_start,
content_block_delta text deltas, message_delta and message_stop, ignores
ping and unknown event types, maps an error event to its error type only,
and requires message_stop. Gemini uses streamGenerateContent?alt=sse,
skips thought parts, maps an error object or promptFeedback.blockReason to
an error and requires a final finishReason. A truncated or withheld reply
(Anthropic max_tokens, model_context_window_exceeded or refusal; Gemini
MAX_TOKENS or a safety/recitation/blocklist reason) fails instead of being
returned as complete, in both the streaming and the non-streaming paths.
DeepSeek and Ollama ordinary payloads still select stream: false; DeepSeek
streams through the OpenAI-compatible adapter.
Token usage (v13)
| Provider transport | Fields read | Streamed usage |
|---|---|---|
| OpenAI | usage.prompt_tokens, completion_tokens, total_tokens, prompt_tokens_details.cached_tokens | through the OpenAI-compatible adapter with with_stream_usage() |
| DeepSeek | as OpenAI, with prompt_cache_hit_tokens | as OpenAI |
| Anthropic | usage.input_tokens + cache creation/read input tokens, output_tokens | message_start usage updated by cumulative message_delta usage |
| Gemini | usageMetadata.promptTokenCount, candidatesTokenCount + thoughtsTokenCount, totalTokenCount, cachedContentTokenCount | the latest usageMetadata of the stream |
| Ollama | prompt_eval_count, eval_count | no streaming transport |
| OpenAI-compatible | usage when the server sends it | final usage chunk when the server sends one; requested only with with_stream_usage() |
Counts come only from these fields. A response without them reports None;
Rullst never estimates tokens or prices them.
Timeouts and cancellation
Every built-in live transport applies a 30-second request deadline by default.
Each provider exposes with_request_timeout(Duration) to select a stricter or
longer deadline, and a loopback regression proves timeout classification on the
OpenAI-compatible transport. This bounds the local request future; it is not
proof that an upstream provider stopped work or billing. The adapters still do
not expose cancellation for ordinary AiProvider calls. AiCancellation
provides an explicit cloneable signal for StreamingAiClient; the compatible
transport and the Anthropic and Gemini streams race it against both the initial
request and every streamed body read. Other provider protocols still use
deadline/drop semantics.
Retries
The adapters make one transport attempt. There is no automatic retry,
backoff, idempotency classification, or retry budget in rullst-ai. An
application-level retry must classify operations carefully and must not assume
that an interrupted provider request was never processed.
These provider-call rules are distinct from AuditDeliveryClient. The latter
supports one to five bounded attempts only for transport/deadline failures,
HTTP 429 and HTTP 5xx, while preserving a caller-supplied event ID. Its receiver
must deduplicate that ID because a timed-out request may already have been
accepted.
Authenticated audit export
AuditDeliveryClient is an opt-in transport for application-minimized audit
events, not an AI provider adapter. It signs the exact JSON body with
HMAC-SHA256 plus a key ID and timestamp, caps an event at 16 KiB and an
acknowledgement at 8 KiB, disables redirects and ambient proxies, and requires
the acknowledgement to bind the original event ID. Cloud endpoints require
HTTPS; literal-loopback HTTP(S) is reserved for development fixtures. Empty or
mock_* keys never use the network.
The receiver owns signature/freshness verification, event-ID deduplication, authorization, persistence, retention, key distribution/rotation and SIEM operations. The client does not automatically attach itself to RAG/tools or provider calls and does not inspect an arbitrary serialized event for secrets.
Adaptive evaluation
AdaptiveAiEvaluator<P> is also independent of the provider capability table.
It wraps the configured text path with mandatory input guards, at most 32
strategy-directed turns, per-turn prompt/response/deadline limits and explicit
cancellation. Strategies classify each bounded response or low-cardinality
guardrail/provider/deadline outcome as passed, failed, inconclusive or another
prompt. Reports are versioned JSON and retain no raw prompt, response or
provider error.
The caller supplies the exact suite and provider/model/configuration subject labels. Rullst neither discovers nor attests that identity. Repository tests prove deterministic orchestration and redaction, not a live provider’s safety; operators must execute and review their versioned corpora against every exact model they deploy.
Tools
ToolRegistry is a separate guarded local execution
boundary. Dispatch requires an exact policy allowlist,
principal authorization, closed JSON validation, payload limits, a call budget
and an audit sink. Destructive and financial calls additionally consume a
one-use approval bound to the exact payload. Applications may select either the
bounded process-local in-memory sink or DurableToolAuditTrail, whose local
versioned file validates restart integrity and fails closed on quota,
corruption, symlink targets and competing-writer growth. The durable sink is
single-process and does not provide authenticity, rotation, retention, backup,
external delivery or principal/approver authentication.
The registry is not connected to any built-in provider transport. Consequently
the provider tools column remains no, and local guarded execution must not be
advertised as provider-native function calling or an autonomous safe agent.
Portable custom-provider default
A custom AiProvider receives a compatibility default of text, chat,
embeddings, and prompt-only JSON because those first three methods are required
by the trait and JSON has a guarded prompt fallback. Custom implementations
that deliberately reject one of those methods, or implement additional native
paths, must override capabilities().
FallbackProvider reports the union of its configured providers. The union
means that at least one provider claims a path; it does not guarantee which
provider will satisfy a particular model-specific request. Text, chat, vision
and JSON requests try the next provider after any failure. Embeddings do not:
vectors from different models are not comparable, so embed uses the first
provider that supports embeddings and returns its error, skipping only
providers that report UnsupportedCapability.
Guarded local AI tools
rullst-ai includes a local tool registry, not provider-native function calling
or an autonomous agent runtime. Model-produced tool names and arguments are
untrusted input. The only execution entry point requires all of these controls:
- an exact registry-level allowlist (
ToolExecutionPolicy); - a principal-specific authorization set (
ToolExecutionContext); - a closed, bounded JSON object validated from
ToolParamdeclarations; - non-zero serialized input/output limits and a per-context call budget;
- a mandatory
ToolAuditSinkthat fails execution closed when unavailable; and - a one-use, exact-payload approval for
DestructiveandFinancialtools.
The application authenticates the principal and approver. Rullst does not infer authorization from model output, a prompt, a role string, or tool registration.
A call to an unregistered tool is denied with ToolExecutionError::ToolNotFound
and audited. When the requested name is not a 1-64 byte [A-Za-z0-9_-]
identifier, the denial and the error carry invalid-tool- plus the first 16 hex
digits of the name’s SHA-256 digest instead, so every sink accepts the record
and the untrusted text is neither stored nor echoed.
Minimal read-only dispatch
#![allow(unused)]
fn main() {
use rullst_ai::ai::{
AiTool, InMemoryToolAuditTrail, ToolExecutionContext, ToolExecutionPolicy,
ToolParam, ToolRegistry, ToolRisk,
};
use serde_json::{Value, json};
struct AccountStatus;
impl AiTool for AccountStatus {
fn name(&self) -> &str { "account_status" }
fn description(&self) -> &str { "Read an account status" }
fn parameters(&self) -> Vec<ToolParam> {
vec![ToolParam {
name: "account_id".into(),
param_type: "string".into(),
description: "Authorized account identifier".into(),
required: true,
}]
}
fn risk(&self) -> ToolRisk { ToolRisk::ReadOnly }
fn execute(
&self,
payload: Value,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
Ok(json!({ "account_id": payload["account_id"], "status": "active" }))
}
}
let mut registry = ToolRegistry::new();
registry.register(AccountStatus)?;
let policy = ToolExecutionPolicy::new(["account_status"])?
.with_payload_limits(4 * 1024, 16 * 1024)?;
let mut context = ToolExecutionContext::new(
"authenticated-user-17",
["account_status"],
3,
)?;
let audit = InMemoryToolAuditTrail::new(128)?;
let output = registry.execute(
"account_status",
json!({ "account_id": "acct-17" }),
&mut context,
&policy,
&audit,
)?;
Ok::<(), Box<dyn std::error::Error>>(())
}
additionalProperties: false is emitted in the exported schema and enforced at
dispatch. Supported parameter types are the bounded JSON primitives string,
number, integer, boolean, object, array, and null. Applications must
perform domain validation inside the tool as well: a JSON string is not proof
that an account identifier belongs to the principal.
Destructive and financial approval
High-risk approval is bound to the exact serialized payload. Changing an amount,
destination, identifier, or any other field produces
ToolExecutionError::ApprovalPayloadMismatch.
The fragment below continues with the registry, context, policy, audit sink and high-risk tool initialized by the application as described above, so it is contextual rather than a standalone program:
use rullst_ai::ai::HumanApproval;
use serde_json::json;
let payload = json!({ "invoice_id": "inv-42", "amount_cents": 1500 });
let approval = HumanApproval::for_payload(
"issue_refund",
&payload,
"authenticated-reviewer-9",
"support ticket FIN-42",
)?;
context.approve(approval);
let result = registry.execute(
"issue_refund",
payload,
&mut context,
&policy,
&audit,
)?;
Ok::<(), Box<dyn std::error::Error>>(())
The approval is consumed once. Its approver and bounded reason are recorded in authorized/success/failure audit events, while the payload itself is omitted to avoid duplicating secrets or personal data in the audit trail.
The sink is called before and after execution. If it rejects the Authorized
record, the tool does not run, a matching approval stays in the context for a
retry after the sink recovers, and the call returns
ToolExecutionError::AuditUnavailable. If it rejects the outcome record after
the tool ran, for example because a durable trail reached its record quota, the
call returns ToolExecutionError::OutcomeUnaudited with the unrecorded outcome
and discards the tool output. The side effect may have happened in that case:
make destructive and financial tools idempotent, or reconcile with the tool’s
own records before approving the same operation again.
Durable local evidence
InMemoryToolAuditTrail is bounded and process-local; it is intended for local
development and tests. A single-process service can instead open the built-in
bounded local trail:
#![allow(unused)]
fn main() {
use rullst_ai::ai::DurableToolAuditTrail;
let audit = DurableToolAuditTrail::try_open("storage/audit/ai-tools.log")?;
Ok::<(), Box<dyn std::error::Error>>(())
}
Each append is synchronized and followed by sync_data. The distinct
versioned tool stream has 16 MiB and 4,096-record default ceilings, validates
every frame and event on restart, and rejects corruption, unsafe file types,
external length changes and quota exhaustion. try_open_with_max_bytes may set
a smaller byte ceiling.
This is a single-process local writer, not an external audit service. Its
SHA-256 frames detect accidental or same-length record corruption but are not a
signature or HMAC. The host owns trusted directory permissions, exclusive
writer operation, rotation, retention, backup, incident export and deletion
policy. Multi-instance applications should implement ToolAuditSink over an
appropriate durable destination.
Production boundary
The registry deliberately does not provide network fetchers, shell execution, database ownership policy, distributed budgets, provider tool-call parsing, or human identity verification. Tools providing those capabilities must enforce their own domain authorization and egress policies. The provider capability matrix therefore continues to report provider tools as unsupported.
Security architecture and boundaries
Vision preserved: unfinished enterprise controls and absolute former claims remain visible, with a recommendation for each, in the capability ledger.
rullst-security contains composable controls for HTTP applications. The crate
does not install every control automatically and does not replace secure domain
logic, a trusted reverse proxy, operating-system hardening, or independent
security testing. To choose between overlapping Core and rullst-security
layers, and for one recommended production stack, see
which security layer to use, and when.
The security assurance case connects these boundaries to
the threat models, secure design principles and tested countermeasures.
Defense in depth
flowchart LR
Peer[Trusted peer identity] --> Edge[Rate limit and honeypots]
Edge --> Request[CSRF, CSWSH and bounded RASP/WAF]
Request --> Identity[Authentication, MFA and RBAC]
Identity --> App[Application and parameterized data access]
App --> Response[Secure headers and supported DLP filtering]
App --> Audit[Tamper-evident audit records]
Each arrow is an application integration point. If a layer is not mounted, its counter and API being present in the crate do not protect traffic.
Cloud and VPS deployments
The application controls work in a compatible cloud container or VPS just as
they do in a local HTTP server. Their effect depends on the configured routes,
middleware, identity and persistence. Server mounts the Core baseline;
additional Security controls require explicit composition.
| Scope | Current boundary |
|---|---|
| Sites and API backends | Bounded WAF/RASP inspection, HTML sanitization, CSP/security headers, login abuse controls, ownership/role helpers, WebSocket-origin validation and audit primitives are available. Application policy and negative route tests remain necessary. |
| Multiple application instances | The opt-in Redis limiter has an atomic shared-budget contract. Production must require distributed mode explicitly. Process-local jails, bans and counters do not become shared because the application has more replicas. |
| Reverse proxy | Verified socket identity is the default. Core’s TrustedProxyLayer (Server::trusted_proxies or [security] trusted_proxies) accepts a forwarded address only from listed proxy networks; an arbitrary client header cannot establish the rate-limit or ban identity. |
| Host and cloud account | OS/SSH patching, firewall rules, IAM, secret custody, backup/restore and privileged container configuration belong to the deployment operator/provider. The crate does not administer these systems. |
| Network availability | Application request limits can reduce specific abuse. Network saturation and upstream DDoS filtering require infrastructure controls outside the application process. |
The v13 deployment candidate exercises two real application processes with an existing proxy, shared Redis budgets, forged forwarding metadata and HTTP/WebSocket controls. Local acceptance passes, and hosted workspace and installed-archive checks passed in PR #224; the final release campaign remains separate. It does not certify a cloud account, VPS or production topology.
The separate v13 local configuration diagnostic,
cargo rullst deploy:doctor, reuses Core validation with explicit environment-source
selection and bounded, redacted reports. It identifies what was actually
inspected and lists unobserved controls; its source merged in PR #225 after
hosted checks, and its archive gate passed in a later exact-commit run.
It does not label configuration as deployed evidence, modify firewall/SSH/cloud
settings or certify a host. No new security crate is introduced. See the
delivery plan.
Canonical production preset
ProductionPreset::middleware_order() is the machine-readable ordering
contract (introduced in v12). From the outermost inbound boundary to the handler, the order is:
- trusted-proxy policy;
- request-body limit;
- request ID;
- tracing;
- secure headers;
- explicit CORS allowlist;
- bounded WAF/RASP;
- CSRF;
- session validation;
- authentication;
- tenant membership resolution;
- role, permission and object-ownership authorization;
- identity/direct-peer rate limit;
- application handler.
Tower response flow unwinds in reverse, so the secure-header layer still
observes and protects responses returned by the inner guards and handler.
Server mounts the framework-owned staging/production baseline. Session,
authentication, tenant membership and authorization are deliberately
application-owned: the framework cannot infer those policies without creating
an insecure universal default. Generated applications must mount those layers
in the declared slots, and protected parameterized routes must still perform an
object-level ownership check.
The direct socket peer is the default network identity. A deployment must not
accept Forwarded or X-Forwarded-For until its exact proxy hops are configured
and tested. Core implements the first stage as TrustedProxyLayer, which
Server::trusted_proxies mounts outside the security baseline, lifecycle,
Traffic Shield and rate limiter in both the static and hot-reload servers (see
identity and network trust). Body limits must be
outer to any middleware that buffers content.
Webhook routes may bypass browser CSRF only by exact path and only when their
provider signature middleware is mandatory.
Academy production-boundary diagnostic
ProductionPreset::academy() layers twelve Academy-specific integration
requirements over the canonical middleware order. Missing observations become
NOT_EVALUATED, duplicate observations are rejected, and validation succeeds
only when every requirement has one explicit PASS. The contract covers
identity, school membership, entitlements, object authorization, tenant
isolation, assessments, score events, durable automation/audit, content safety,
privacy and distributed abuse controls. It cannot inspect or certify a deployed
topology by itself.
cargo rullst academy:doctor emits the normalized contract in text or JSON and
returns a failing exit status until all requirements pass. An evidence document
uses the versioned rullst.academy-evidence.v1 schema:
{
"schema_version": "rullst.academy-evidence.v1",
"checks": [
{
"requirement": "authenticated_identity",
"status": "PASS",
"evidence": ["test:session_rejects_forged_identity"]
}
]
}
Run it with cargo rullst academy:doctor --evidence academy-evidence.json --json. Omitted checks remain NOT_EVALUATED, and a PASS without a non-empty
evidence reference is invalid. Evidence strings are caller declarations rather
than independently verified proof. The output therefore always includes
certification: false; satisfying this contract is not an audit, grade or
production-readiness claim. Process-level CLI tests cover both the incomplete
failing report and a complete declared-evidence report.
Parameterized-route access contract
cargo rullst audit --idor requires every recognized parameterized route to
carry an adjacent // rullst-access: public|owner|role|admin — reason marker.
public is accepted only for a recognized GET route. owner requires
RbacGuard::authorize_owner_or_role; role requires a recognized role guard;
and admin requires RequireRoleLayer or
NexusAuthPolicy::protect_router. The latter lets application operational
routes reuse the same fail-closed peer/credential and administrator boundary as
Nexus. The guard is looked up in the same crate’s src tree, outside comments
and #[cfg(test)] items; it need not be in the route’s file.
The marker records intent and the scanner catches common omissions; neither is a proof of domain ownership. Protected object routes still need negative HTTP tests in which an authenticated subject requests another subject’s resource and receives a denial before data or side effects are exposed.
Control map
| Risk area | Available primitives | Boundary |
|---|---|---|
| Broken access control | RbacGuard, ownership helpers, authenticated user context. | The application must apply a check to every protected object and operation. |
| Cryptographic storage | AES-256-GCM field encryption and zeroizing secret wrappers. | Operators own key generation, storage, rotation, separation, and recovery. |
| Injection | SQLx binds, strict identifier validation, sanitizer and bounded RASP patterns. | Heuristics are not a complete language parser; domain validation and parameterization remain mandatory. |
| Misconfiguration | Nonce-based CSP and a strict HTTP-header baseline. | Proxies and page content change the deployed policy; no scanner grade is guaranteed. |
| Authentication abuse | Login jail, local limiter, optional atomic Redis limiter, timing helpers, TOTP, subject-bound recovery-code verifiers and WebAuthn integration. | Real Redis topology/failover, durable transactional recovery consumption and UX, RP/origin configuration, trusted peer identity, and capacity planning remain application concerns. |
| Data integrity | HMAC audit records plus an opt-in bounded HMAC-chained local SIEM journal with named rotation keys. | Whole-tail rollback detection, independent checkpoints, multi-writer operation, key protection, retention and remote delivery require external storage and operations. |
| Data leakage | Text-aware DLP, PII masking, and log redaction helpers. | Unsupported content types, encodings, streams, and oversize bodies follow explicit policy and must be tested. Core response masking buffers at most 2 MiB of identity-encoded text and scans it in linear time; it is a heuristic for emails and 13-19 digit card-like runs, not complete PII detection. JSON responses are masked only inside string values, so numbers such as millisecond timestamps and 64-bit IDs keep their digits and the body stays valid JSON; other textual responses, including JavaScript, are masked as plain text. A 206 Partial Content response that masking would change is replaced by a 502 with Cache-Control: no-store, because a masked range no longer matches its Content-Range; a clean range passes through unchanged. Core masking and DlpResponseLayer split a multipart/byteranges response into its parts, check each textual identity-encoded part with the masker of its own media type and withhold the response the same way when a part would be masked or the body cannot be split exactly (an invalid boundary or delimiter line, more than 256 parts, a part header block over 8 KiB or no close delimiter). Each range is inspected on its own, so neither layer recognizes a value split across separately requested ranges, and Core does not recognize PII in a JSON range that starts inside a string literal; tower-http ServeDir, which serves Core static files, answers multi-range requests with 416 and never emits multipart/byteranges. An e-mail match needs an alphabetic top-level domain that is not a static-asset extension, so versioned package URLs ([email protected]) and density-suffixed asset names ([email protected]) are left unchanged. |
| AI input risk | Prompt-injection heuristics and PII masking in the high-level AI client. | No heuristic can prove a prompt safe or guarantee detection of every secret. |
This is a control mapping, not a claim of complete OWASP Top 10 coverage.
Identity and network trust
Rate limiting, bans, honeypots, and audit attribution use the direct peer address
unless a trusted-proxy policy explicitly accepts forwarded metadata. Core’s
RateLimiter keys IPv4 peers per address and IPv6 peers per /64, so rotating
addresses inside one delegated prefix shares a bucket. Its process-local map
tracks at most 100,000 keys: fully refilled buckets are dropped and, beyond the
cap, the least recently used buckets are evicted and restart with a full burst.
Security’s rate_limit_middleware groups peers the same way; its table tracks at
most 16,384 keys and fails closed for new keys while it is full. Never trust
X-Forwarded-For, tenant headers, or role headers directly from an arbitrary
client. Tenant membership and roles must come from an authenticated session or a
cryptographically trusted internal gateway.
Trusted-proxy client resolution
rullst_core::security::TrustedProxyLayer is the explicit policy for
deployments behind reverse proxies. TrustedProxyConfig::new accepts at most 64
IPv4/IPv6 addresses or CIDR networks; it rejects /0, host bits and duplicates,
and normalizes IPv4-mapped IPv6 entries. An empty policy never reads forwarding
headers. List only the networks your own proxies connect from: any host inside a
listed network can choose the client address.
- Only when the socket peer is trusted does the layer read exactly one header:
X-Forwarded-For(default) or RFC 7239Forwarded(with_header). The two are never merged, and header lines form one list in their received order. - The list is walked right to left, skipping trusted proxies; the first untrusted address is the client, and if every hop is trusted the left-most is used. Text to the left of the client entry is never parsed, so a client cannot choose its identity or force a fallback by padding its own header.
- Ports, IPv6 brackets and IPv4-mapped addresses are normalized.
unknown, obfuscated_identifiers, unquoted RFC 7239 IPv6/ports, duplicate parameters and other malformed entries are rejected. At most 4,096 bytes and 32 entries are examined from the right. - A missing, malformed, oversized or over-long header from a trusted peer keeps
the socket peer, never fails the request and emits one
tracingevent (debugwhen missing, otherwisewarnwith the reason) naming the header and peer but never the header contents. - On success the request’s
ConnectInfo<SocketAddr>becomes the client IP with port 0, so every existing consumer (Core and Security rate limiters, honeypots, the error console and Nexus lockout) uses it unchanged. Every request with peer metadata also receives aClientAddrextension (client IP, original peer,via_trusted_proxy), which has no public constructor. trust_forwarded_proto(true)additionally reports the scheme from that trusted peer asClientAddr::forwarded_proto: one unambiguousX-Forwarded-Protovalue, or theproto=of the selectedForwardedelement. Nexus accepts anhttpsreport as TLS evidence for Basic Auth. Enable it only when every trusted proxy overwrites the value.
The hot-reload development server now also supplies ConnectInfo from the
accepted socket. The layer does not authenticate a proxy beyond its network
address, cannot detect a trusted proxy that forwards client-supplied headers
unchanged, and does not replace authenticated per-user limits or distributed
rate-limit state.
Request and response inspection
RASP/WAF rules are bounded to avoid uncontrolled CPU or memory work. They can
reject known suspicious patterns in supported URI, header, and bounded body data,
but application queries must still use binds and access control. Body media
types are classified at least as broadly as axum’s extractors: any ASCII case,
parameters, a json/xml subtype or +json/+xml suffix, and any type that
starts with application/x-www-form-urlencoded. Core’s WAF checks the
percent-decoded request path for traversal signatures only (../, ..\,
/etc/passwd, win.ini), since routers decode path parameters; its SQL, XSS
and command signatures apply to the query, the Referer, each cookie pair and
bounded bodies (JSON key by key and string by string). SQL and shell signatures
match injection structure, such as a quote breakout, a chained statement,
union select or a metacharacter before a command name, never a keyword alone,
so ordinary text like “select an option” passes.
DLP modifies only supported textual responses whose body can be safely buffered
within configured limits. Applications must test JSON, HTML, binary, compressed,
SSE, streaming, and oversized responses so headers and bodies remain
protocol-correct. Masking and log redaction run in time linear in the inspected
length. Database URL passwords are masked only inside the URL authority, which
ends at the first /, ?, #, whitespace, quote, <, >, backtick or
control character, or after 2,048 bytes; unencoded delimiters in a password
therefore end detection early.
Cryptography providers
Rullst uses two cryptography libraries, each with one role (since 13.0.0-alpha.4):
| Component | Library | Why |
|---|---|---|
Every TLS connection Rullst makes: outbound HTTPS (reqwest: AI and payment providers, OAuth, webhooks, S3 storage, Studio’s remote poller), SQLx (PostgreSQL and MySQL), Redis (Redis Streams messaging, ORM Redis data and cache), SMTP (mail-smtp), MongoDB (orm-mongodb) and OTLP telemetry | aws-lc-rs, as the rustls crypto provider | Post-quantum hybrid key exchange (X25519MLKEM768, offered first) and a FIPS-capable build |
HMAC, digests, randomness and signatures in rullst-auth, rullst-capital, rullst-core, rullst-labs, rullst-media, rullst-privacy, rullst-studio and rullst-supervision | ring, used directly | Portable to wasm and edge targets, where aws-lc-rs is not available |
ring is not a rustls provider in Rullst: no Rullst crate enables rustls’ ring
feature. JWT signing (rullst-auth jwt) uses jsonwebtoken’s aws_lc_rs
backend.
How each client chooses its provider:
- SQLx and MongoDB select aws-lc-rs at compile time (
tls-rustls-aws-lc-rs,rustls-tls-aws-lc). SQLx switches to ring as soon as any crate in the application enables one of its ring TLS features (tls-rustls,tls-rustls-ringand thetls-rustls-ring-*variants), so an application’s ownsqlxdependency should usetls-rustls-aws-lc-rs, as generated projects do. - reqwest (and therefore the OTLP exporter) and lettre use the rustls process default when one is installed, otherwise aws-lc-rs.
- Redis uses only the process default. When none is installed and the
application’s dependencies enable both ring and aws-lc-rs, rustls cannot
choose and panics, so Rullst installs aws-lc-rs before a
rediss://connection.
Rullst installs aws-lc-rs as the rustls process default when none is
installed: at Server start-up, before telemetry and the database pool, when
Core’s orm, storage-s3 or telemetry feature is enabled (the Core features
that build rustls clients); when the OTLP exporter builds its HTTP client; before
a Redis TLS connection; and in the standalone rullst-studio binary. It never
replaces an installed provider, so an application chooses its own by installing
it first, at the top of main, before Server::run and before it builds any
client:
#[rullst::runtime::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// With `rustls = { version = "0.23", features = ["fips"] }`; any rustls
// `CryptoProvider` works the same way.
rustls::crypto::default_fips_provider()
.install_default()
.map_err(|_| "a rustls provider was already installed")?;
// Build clients and start the `Server` here.
Ok(())
}
Rullst does not enable rustls’ fips feature (it needs CMake and Go to build
the AWS-LC FIPS module) and makes no FIPS validation claim. An application
that adds a ring rustls feature of its own can keep aws-lc-rs as the default
this way; SQLx then still uses ring, as described above.
Audit chains
Audit records use an unambiguous canonical representation and an HMAC key of
at least 32 bytes (MIN_AUDIT_KEY_BYTES) with at least eight distinct byte
values. Verification must cover the ordered sequence, not just isolated
records. A valid chain is tamper-evident; it cannot stop an attacker who can
delete every record or steal the key. Store the log and key in separate
protected systems. A new AuditChain starts at sequence 1 from the genesis
predecessor, so a restarted writer must continue its persisted trail with
AuditChain::try_resume (added in v13), passing the newest persisted record;
that record’s HMAC must verify. Exactly one writer may own a persisted chain, and deleting
the newest records before a restart is detected only against an external
checkpoint of the last sequence and hash.
Supply-chain and compliance evidence
Repository workflows can run dependency policy, RustSec, CodeQL, fuzzing, sanitizers, SemVer checks, SBOM generation, and release attestations. Their scope and blocking status are defined by the workflow files. Informational checks must not be described as formal gates.
Neither those workflows nor this crate certify an application for SOC 2, ISO
27001, PCI DSS, FedRAMP, or any OWASP level. See AUDIT.md
and the external audit kit for reproduction and
evidence requirements. cargo rullst audit --compliance writes a bounded
SECURITY_COMPLIANCE.md evidence report for one application; it is generated,
not kept in the repository, and is not a certification.
Deployment checklist
- Mount middleware in a tested order and use exact webhook CSRF exemptions.
- Configure secure session, webhook, audit, and encryption keys; reject weak or empty live credentials.
- Define trusted proxies with
Server::trusted_proxies(or[security] trusted_proxies) listing only the real proxy networks, and use the direct peer as the default identity. - Apply owner/tenant/role checks server-side for every data operation.
- Validate per-response CSP nonces against rendered pages.
- Export telemetry and audit records to durable, access-controlled storage.
- Run negative integration tests and an independent security review for the application’s actual configuration.
Which security layer to use, and when
Rullst has two places for security code, and some of their controls look alike:
rullst-core(rullst::security) owns the runtime baseline: secure headers with a CSP nonce, CORS, a small WAF, double-submit CSRF, optional response PII masking, trusted-proxy client resolution, exact machine endpoints and tenant context.Servermounts the baseline for you in staging and production.rullst-security(rullst::security_runtime, or therullst_securitycrate that generated applications already depend on) is a toolbox of opt-in layers and helpers: RASP, response DLP, configurable headers, honeypots, CSWSH, JSON schema guards, rate limiters, the login jail, MFA, RBAC, audit chains, field encryption, SRI and more. Nothing in it protects traffic until you mount or call it.
This page tells you which one to pick for each job, gives one recommended
production stack and states what each layer does not protect against. It
summarizes the code in rullst-core/src/security/ and rullst-security/src/;
the security architecture page holds the deeper
contracts, and both crates are in the Core maturity tier.
The short answer
- Run your application through
Serverand configure[security]inRullst.toml. In staging and production that gives you headers, CORS, the WAF and CSRF without extra code. - Behind a reverse proxy, set
Server::trusted_proxies(or[security] trusted_proxies) to your proxy networks only. - Add from
rullst-securityonly what your application needs: the honeypot and response DLP for every app, the CSWSH guard on WebSocket routes, a JSON schema guard on JSON write routes, and the login jail, MFA, RBAC and audit chain inside your handlers. - Do not stack two layers that do the same job and expect double protection. The pairs below explain which one wins and what each one misses.
What generated applications already have
| Starter | Mounted for you | Not mounted |
|---|---|---|
| Every starter | Server applies the Core baseline in staging and production: secure headers, CORS (only when cors_allow_origins is set), WAF and CSRF. PII masking stays off unless enable_pii_masking = true. In development the baseline adds only CORS (and machine-endpoint authentication when configured) and a report-only WAF that logs what it would refuse. | No rullst-security middleware layer (RASP, DLP, SecureHeadersLayer, honeypot, CSWSH, schema guard) is mounted by any starter. No global rate limit or Traffic Shield is configured. Trusted proxies are configured only when cargo rullst deploy adds its Caddy proxy address to [security] trusted_proxies. |
| Blank (full stack), SaaS (and the LMS, ERP and Blog examples) | Core csrf_middleware and headers_middleware on the router too, so development behaves like production. | — |
| API (Blank JSON API) | Core headers_middleware on the router. The example write route POST /api/messages is an exact machine endpoint (Server::with_machine_endpoints): clients send Authorization: Bearer <API_TOKEN> (a random value in the generated .env), requests with cookies are refused, and startup fails without API_TOKEN. | No CSRF layer on the router. In staging and production the Server baseline requires CSRF on every other write route, so add each new JSON write route to machine_endpoints() in src/main.rs (a {id} segment covers routes such as PUT /api/posts/{id}), or send the double-submit token from a browser. make:controller --api prints these lines. |
| Portfolio example | Only the Server baseline (staging and production). | No router-level layers in development. |
| SaaS (and the LMS example) | A Core token-bucket limit on the credential routes; the SaaS [security] section exempts the exact signed billing webhook path from CSRF. The LMS example uses rullst_security::{UserContext, RbacGuard}. | Login jail, MFA (add it with make:mfa), audit chain. |
Every generated Cargo.toml already lists rullst-security, so adding a layer
needs no new dependency.
Since 13.0 every generated project also has src/security_tests.rs, which
cargo test runs offline against the project’s own router() wrapped in the
staging/production baseline (apply_security_baseline, as Server composes
it). It checks the security headers, that a write without the CSRF token is
refused and one with it passes, and that the WAF refuses an injection probe
but accepts the prose “Please select an option”. SaaS adds the sign-in rate
limit and the API starter the bearer token of its machine endpoint; the LMS
example also checks the owner of its lesson routes. Keep these tests passing as you
change routes; they do not replace your own authorization tests.
Recommended production composition
From the outermost layer to your handler:
| # | Layer | Owner | How you enable it |
|---|---|---|---|
| 1 | Trusted-proxy client resolution | Core | Server::trusted_proxies or [security] trusted_proxies |
| 2 | Secure headers and CSP nonce | Core baseline | Automatic in staging/production; tune [security] csp and coep |
| 3 | CORS allowlist | Core baseline | [security] cors_allow_origins |
| 4 | WAF | Core baseline | Automatic in staging/production |
| 5 | CSRF (and exact machine endpoints) | Core baseline | Automatic; Server::with_machine_endpoints for API, webhook and mTLS routes |
| 6 | Readiness and drain, Traffic Shield, per-peer rate limit | Core Server | with_lifecycle, shield, rate_limit |
| 7 | Honeypot trap paths and peer bans | rullst-security | HoneypotLayer |
| 8 | Response secret masking | rullst-security | DlpResponseLayer |
| 9 | Per-route guards | rullst-security | cswsh_guard_middleware on WebSocket routes, json_schema_guard_middleware on JSON write routes |
| 10 | Session, authentication, tenant and authorization | Your application | Your auth middleware, TenantContext, RbacGuard |
| 11 | Inside handlers | Both | LoginGuard, a RedisRateLimiter for credential routes, TOTP and recovery codes, AuditChain, FieldEncryptor |
Server composes layers 1 to 6 itself (see rullst-core/src/server/stack.rs);
you add 7 to 11 to your router. The canonical production preset
is the full machine-readable ordering contract, including request-body
limits, request IDs and tracing.
use axum::{Extension, Router, middleware::from_fn};
use rullst::security::TrustedProxyConfig;
use rullst::{RateLimitConfig, RateLimiter, Server};
use rullst_security::{
CswsPolicy, DlpResponseLayer, HoneypotLayer, HoneypotState, cswsh_guard_middleware,
};
/// `pages` holds the application routes, with session, authentication and
/// RBAC checks inside them; `live` holds the WebSocket upgrade routes.
async fn serve(pages: Router, live: Router) -> Result<(), Box<dyn std::error::Error>> {
// Layer 9: reject cross-site WebSocket upgrades.
let live = live
.route_layer(from_fn(cswsh_guard_middleware))
.layer(Extension(CswsPolicy::try_new(["https://app.example.com"])?));
let app = pages
.merge(live)
// Layer 8: mask private keys, AWS key IDs and database URL passwords.
.layer(DlpResponseLayer)
// Layer 7: refuse trap paths such as /.env and ban direct scanners.
.layer(HoneypotLayer::new(HoneypotState::default()));
Server::new(app.into())
// Layer 1: accept forwarded client addresses only from your proxies.
.trusted_proxies(TrustedProxyConfig::new(["10.0.0.0/8"])?)
// Layer 6: process-local token bucket per IPv4 address or IPv6 /64.
.rate_limit(RateLimiter::new(RateLimitConfig::per_minute(600.0)))
.run(3000)
.await?;
Ok(())
}
The snippet is marked ignore: it shows the shape and the real API names, but
the book does not compile it. Replace the example origin and proxy network with
your own. rullst::RateLimiter (Core token bucket) and
rullst_security::RateLimiter (fixed window) are different types. The WAF,
RASP and schema guards buffer at most 1–2 MiB of a body themselves; set an
explicit limit on upload routes too (for example axum’s DefaultBodyLimit).
Leave these out unless you have a specific reason:
RaspSecurityLayeroverlaps the Core WAF and buffers the same request body a second time. Add it when you need its extra signatures (cloud metadata hosts, JNDI lookups and every non-credential header) and accept more false positives.- Core PII masking rewrites e-mail addresses and long digit runs in every textual response, including ones your users are meant to see. Prefer redacting fields in your own serializers.
SecureHeadersLayerinsideServeronly when you need values that[security]cannot express: the Core baseline then keeps every header the layer chose or omitted (see below).
Overlapping layers compared
Secure headers: Core baseline vs SecureHeadersLayer
Core headers_middleware | rullst_security::SecureHeadersLayer | |
|---|---|---|
| Mounted by | Server in staging/production; most blueprints also mount it on the router | You, as a Tower layer |
| Configuration | [security] csp (template with {NONCE}), coep and the hsts_* settings; other values fixed | SecureHeadersConfig: every header value replaceable or omitted (None), CSP template or static CSP |
| Headers | HSTS (one year with includeSubDomains, no preload, by default), X-Frame-Options: DENY, nosniff, X-XSS-Protection: 0, Referrer-Policy, Permissions-Policy, COOP, CORP, COEP, CSP | The same set, from the config; the default HSTS value is the same |
| Existing values | Adds each header only when the response has none; leaves all of them alone after an inner SecureHeadersLayer | Replaces the handler’s values with its configured ones |
Cache-Control | Adds no-store when the handler set none | Not touched |
| CSP nonce | CspNonce request extension | Same CspNonce, reused when one already exists |
Both layers reuse one CspNonce per request, so templates render a nonce that
matches the final header. The Core baseline is a fallback: it fills in a
security header only when the handler or an inner layer has not set it, so an
explicit value set by the application wins over the baseline.
SecureHeadersLayer marks its responses with
rullst::security::SecurityHeadersApplied; when the Core baseline (outermost
inside Server in staging and production) sees that marker it leaves every
security header to the layer, so the headers SecureHeadersConfig sets to
None stay absent. It still adds Cache-Control: no-store when no cache
policy was set. The trade-off: a weaker value that a handler sets on purpose
or by mistake (for example Referrer-Policy: unsafe-url or a CSP with
'unsafe-inline') is no longer replaced, so review the headers your handlers
set. Use [security] settings to tune a Server application, and
SecureHeadersLayer when you need per-header values or omissions, or for a
plain Axum router that you serve without Server. An exact
Referrer-Policy: no-referrer from a handler survives both layers, normalized
to one value. CspSecurityLayer (in sanitizer) is a smaller variant: default
CSP, X-Frame-Options, nosniff and Referrer-Policy only; it does not set
the marker, so the Core baseline keeps those four and adds the rest.
Strict-Transport-Security
The Core baseline sends Strict-Transport-Security: max-age=31536000; includeSubDomains unless the response already has the header. Three
[security] settings choose another value:
[security]
hsts_max_age_seconds = 63072000 # default 31536000 (one year); 0 clears the policy
hsts_include_subdomains = true # default true
hsts_preload = true # default false
includeSubDomains makes browsers refuse plain HTTP on every subdomain of the
host for the whole max-age, so leave it on only when all of them serve HTTPS.
preload asks to be listed in the HSTS preload list built into browsers once
the domain is submitted there, and leaving that list takes months, so it is
opt-in. Startup fails when hsts_preload is set without
hsts_include_subdomains or with a max-age below 31536000, the list’s own
requirements. A handler that sets the header, or SecureHeadersConfig::hsts
in SecureHeadersLayer, still wins over these settings. 13.0.0-alpha.1 and
earlier always sent max-age=63072000; includeSubDomains; preload.
Not covered: headers reduce browser-side risks (framing, MIME sniffing,
inline script injection); they do not fix an XSS bug in a page that allows
'unsafe-inline' or a trusted script host that serves attacker content, and
HSTS has no effect until the first HTTPS response reaches the browser.
Request inspection: Core WAF vs RASP
Core waf_middleware | rullst_security::RaspSecurityLayer | |
|---|---|---|
| Mounted by | Server in staging/production; in development a report-only variant that logs instead of refusing | You |
| Signatures | Injection structure, not keywords. SQL: a quote followed by or/and and a comparison or by a comment (' or '1'='1, admin'--), a ; followed by a statement (; drop table, ; delete from, ; select *), union [all] select, and probes (sleep(, benchmark(, waitfor delay, pg_sleep(, information_schema, @@version, xp_cmdshell, load_file(, into outfile). Shell: ;, |, &&, a backtick or $( followed by a command name (sh, bash, cat, ls, id, curl, wget, nc, rm, python…). XSS (<script, javascript:, onload=, onerror=, document.cookie) and traversal (../, ..\, /etc/passwd, win.ini) as substrings | More specific phrases: SQL (union select, ' or '1'='1, sleep(, information_schema…), traversal, SSRF hosts (169.254.169.254, metadata.google.internal), shell (cmd.exe, /bin/sh…), JNDI (${jndi:…) |
| Where it looks | Decoded path (traversal only), query, Referer, each cookie pair, User-Agent against a configurable blocklist (AI and SEO crawlers by default) | Full request target, every header except Cookie and Authorization, body; JSON keys and strings after decoding |
| Bodies | text/*, JSON, XML, URL-encoded forms; identity encoding only (others get 415); at most 1 MiB (413); invalid UTF-8 gets 400 | Same media types and limits |
| Decoding | One percent-decoding pass, then lowercase with whitespace runs collapsed, checked with and without /* … */ comments; JSON bodies are checked key by key and string by string | Raw text plus one percent-decoding pass |
| Telemetry | None | SecurityStore interception counters and events |
The Core WAF is a coarse baseline. Ordinary text that names SQL or shell words (“please select an option”, “delete my account”, “curl the API with your token”) passes; only injection syntax around those words is refused. Parameterized SQL is the real defense against SQL injection, and shell-free process APIs against command injection.
Seeing and accepting WAF false positives
The WAF refuses requests only in staging and production. In development
Server mounts the same rules in report-only mode: each request they would
refuse passes, and a tracing warning from rullst::security::waf names the
rule and the path, for example rule="body-signature" path="/lessons/answers".
The warning never includes the query, headers or body. The rules are
user-agent-blocklist, path-traversal, query-signature,
referer-signature, cookie-signature, body-signature, body-encoding,
body-too-large and body-not-utf8.
A route whose body must carry text the signatures refuse, such as code
samples in a programming lesson (../, javascript:, document.cookie,
| grep), can be exempted by exact path:
[security]
waf_body_exempt_paths = ["/lessons/answers"]
The WAF then neither reads nor inspects bodies sent to that exact path, so it
also stops checking their encoding and size; set a body limit on the route
(DefaultBodyLimit). Its path, query string, cookies, Referer and
User-Agent stay inspected, and CSRF still applies. Entries must be exact
absolute paths: wildcards, route parameters ({id}, :id), queries,
fragments, dot segments and duplicates fail validation, so the server refuses
to start. Each entry is a deliberate risk acceptance for that route: the
handler alone (parameterized SQL, output encoding, validation) must make the
body safe.
Not covered by either: binary bodies, multipart/form-data file contents
and file names, multipart text fields that are not read with
rullst::uploads::Multipart (which applies the Core WAF signatures to each
field it reads; RASP does not inspect multipart at all; see
form uploads),
double-encoded or otherwise obfuscated payloads (HTML entities, Unicode
escapes outside JSON strings, and in RASP SQL comments between keywords),
payloads split across fields, injections that need no quote, ; or
union (a bare numeric 1 or 1=1), and every attack class without a listed
signature. Both still produce false positives: the Core WAF refuses text in
which a shell metacharacter precedes a command name (a Markdown cell
| id |, inline code such as `ls`) or a quote precedes or and a
comparison, and RASP refuses any field that contains ../ or /bin/sh.
Parameterized SQL, output encoding, typed validation and URL allowlists remain
the actual defenses.
Response masking: Core PII masking vs DLP
Both layers inspect responses only; neither looks at requests. (Prompt PII
masking for LLM calls lives in rullst-ai.)
Core pii_masking_middleware | rullst_security::DlpResponseLayer | |
|---|---|---|
| Enabled by | [security] enable_pii_masking = true (staging/production) | You |
| Finds | E-mail addresses; 13–19 digit card-like runs (digits with at most two spaces or hyphens between them; no Luhn check) | Complete PEM private-key blocks (PKCS#8, RSA, EC, DSA, OpenSSH, OpenPGP), AKIA access-key IDs, passwords in postgres, postgresql, mysql, redis and rediss URLs |
| Media types | text/* except text/event-stream, JSON (masked only inside string values), XML, application/javascript | text/* except text/event-stream, JSON, XML, YAML, application/javascript and its aliases |
| Bounds | Fixed-size, identity-encoded bodies up to 2 MiB; streams, unknown sizes, compressed and larger bodies pass unchanged; HEAD, 204 and 304 skipped | Same |
206 Partial Content | A range that would change becomes 502 with no-store; a clean range passes | Same |
multipart/byteranges | Split into parts (at most 256 parts, 8 KiB of headers per part); withheld with 502 when a textual part would change or the body cannot be split | Same |
Not covered: a value split across separately requested ranges, any
compressed or streamed response, other secret formats (temporary ASIA keys,
AWS secret keys, API tokens, JWTs, passwords outside a database URL), PII
other than e-mails and card-like numbers, and values encoded in a way the
pattern does not match. Core masking also hides legitimate phone numbers, order
numbers and e-mails. Neither layer stops a leak through logs, files or another
channel; use redact_secrets for log fields and keep secrets out of responses
in the first place.
Browser and abuse controls
| Control | Owner | Reduces | Mechanism | Does not protect against |
|---|---|---|---|---|
| Double-submit CSRF | Core | Cross-site form and fetch writes using ambient cookies | rullst_csrf cookie must match X-CSRF-Token or the _token form field, compared in constant time. A GET for a static-looking path (/robots.txt, /sitemap.xml, /favicon.ico, /static/*, or a .txt, .xml, .ico, .json, .css, .js, .png, .jpg, .jpeg, .svg, .webp or .wasm suffix) never mints the token; it only reuses an existing cookie’s | State changes in GET handlers; a sibling subdomain that can set cookies; an XSS bug that reads the token; WebSocket upgrades |
| Exact machine endpoints | Core | Webhook and API routes that must skip browser CSRF | Exact method and path, authenticated (bearer digest, signed-webhook or mTLS verifier) before the CSRF exemption applies | A weak verifier you supply; replay unless your verifier checks freshness |
| Trusted-proxy resolution | Core | Spoofed X-Forwarded-For choosing the client identity | Forwarded headers read only from listed proxy networks, right to left | A listed proxy that forwards client headers unchanged; any host inside a listed network |
| Tenant context | Core | Client-chosen tenant headers | Only tenant context inserted by your authentication middleware is used | Missing ownership checks in handlers |
Rate limit (Server::rate_limit) | Core | Per-peer request floods | In-memory token bucket per IPv4 address or IPv6 /64, at most 100,000 keys (LRU) | Multiple instances (each has its own budget), botnets, network-level DDoS |
RedisRateLimiter | rullst-security (redis-rate-limit feature; facade security-redis) | Budgets shared across instances | Atomic fixed-window Lua script in Redis | Redis outages and failover semantics; call require_distributed() so a mock URL fails at startup |
rate_limit_middleware, RateLimiter | rullst-security | Per-peer floods in one process | Fixed window, 16,384 keys, fails closed for new keys when full | Same as the Core limiter; prefer one limiter per policy |
LoginGuard | rullst-security | Password guessing on one identity | Progressive delay and an in-memory jail per hashed identity | Distributed guessing across identities; restarts and other instances (state is local) |
Honeypot (HoneypotLayer) | rullst-security | Automated scanners probing well-known paths | Exact trap paths; direct hits ban the socket peer for 15 minutes by default | Scanners that avoid the paths or rotate addresses; bans are per process and per exact IP |
deception_trap_middleware | rullst-security | The same, without bans | Global registry of exact trap paths; 403 plus telemetry | Anything beyond recording the probe |
| CSWSH guard | rullst-security | Cross-site WebSocket hijacking with ambient cookies | Exact Origin allowlist, or same host/port/scheme when no allowlist is set; missing Origin rejected by default | Non-browser clients (they can send any Origin); authentication of the socket itself |
| TOTP and recovery codes | rullst-security | Stolen passwords | RFC 6238 TOTP, ±1 step; verify_totp_step_after rejects replayed steps; HMAC-verified single-use recovery codes | Real-time phishing proxies; replay unless you persist the last accepted step |
AuditChain | rullst-security | Silent edits to stored audit records | HMAC-SHA256 chain over sequence, time, fields and predecessor | Deleting the newest records without an external checkpoint; a stolen HMAC key |
FieldEncryptor | rullst-security | Readable sensitive columns in a stolen database copy | AES-256-GCM with AAD and key IDs | An attacker with the key or with application access; key custody is yours |
| SRI tags | rullst-security | A compromised CDN serving altered JS or CSS | SHA-384 integrity attributes | Assets that legitimately change without regenerating the tag |
| JSON schema guard | rullst-security | Oversized, deep, duplicate-key or off-schema JSON | Size (2 MiB) and depth (32) limits, duplicate-key rejection, optional JSON Schema 2020-12 policy | Business-rule validation; the global guard passes non-JSON content types through |
What none of these layers do
- Authorization. No layer knows who owns a record. Check ownership and roles
in every protected handler (
RbacGuard::authorize_owner_or_role) and test the denial. - Injection-proof data access. Use SQLx parameters or
sanitize_identifier; WAF and RASP signatures are a backstop, not a parser. - Network-level protection. Volumetric DDoS, TLS termination, firewalling and host hardening belong to your proxy, provider and operating system.
- Shared state. Bans, jails and most counters are per process. Multiple instances need the Redis limiter or your own shared store.
- Certification. Mounting these layers does not make an application OWASP-, PCI- or SOC 2-compliant.
To collect static evidence of which of these layers a project mounts (headers
and CSP, CSRF, cookie attributes, rate limiting on credential routes), run
cargo rullst audit --report; the security report guide
explains each check and its OWASP ASVS 5.0 Level 1 mapping. The
external audit kit packages scope, threat models, a
sample application and tooling for a third-party reviewer.
See the rullst-security crate page for module details
and the security architecture for the deployment
checklist.
Security report (cargo rullst audit --report)
cargo rullst audit --report [md|html|json] (new in 13.0) writes an evidence
report about one project: a set of bounded static checks mapped to
OWASP ASVS 5.0.0
Level 1, a personal-data inventory and accessibility heuristics. It is
written for a reviewer.
The report does not replace a manual security review or a penetration
test. It never states that a requirement is met, never reports PASS and
is not a certification. A check that finds nothing reports NO FINDINGS: that
check ran and saw nothing it recognizes, nothing more.
cargo rullst audit --report # SECURITY_REPORT.md in the project root
cargo rullst audit --report html # SECURITY_REPORT.html, one self-contained file
cargo rullst audit --report json --output evidence/report.json
The terminal prints a short summary grouped as security checks, personal data
and accessibility, with ✓ (no findings), ! (not checked, exceptions or review
items) and ✗ (findings or error), a fix and a docs link per failing check. The
command exits with status 1 when any check reports FINDINGS or ERROR, and
0 otherwise. Flags, formats and the JSON schema are in the
CLI reference.
Reading a result
| Status | Meaning |
|---|---|
NO FINDINGS | The check ran over the inputs it names and recognized no problem. |
NO FINDINGS OUTSIDE EXCEPTIONS | cargo audit passed only because of --audit-ignore exceptions; those advisories remain open. |
FINDINGS | The check reported items; each has a location and a message. The exit status is 1. |
OBSERVED | The personal-data inventory listed fields. It never changes the exit status. |
NOT CHECKED | The check could not run (for example no src/, no Git work tree or no cargo-audit). It says why. |
ERROR | The check started but could not complete (an unparsable Rullst.toml, an incomplete source walk, a failed cargo audit). The exit status is 1. |
Each check lists the ASVS 5.0.0 requirements it relates to, with their level. Most are Level 1; a few related Level 2 requirements are shown for context (for example the CSP header, V3.4.3). A mapping names what the check relates to; a static scan only partially evidences any of them.
Security checks
Security headers and CSP
Looks for Server::new/Server::new_hot (the Core baseline sends HSTS,
nosniff, frame and CSP headers in staging and production) or a router
header layer (headers_middleware, SecureHeadersLayer). A custom
[security] csp in Rullst.toml is reported when it is empty, has no
script-src/default-src, or allows 'unsafe-inline', 'unsafe-eval' or a
broad script source (*, http:, https:, data:). coep = "unsafe-none"
or an unknown value is reported. Maps to V3.4.1 (L1) and V3.4.3, V3.4.4 and
V3.4.6 (L2). Response-level tests against the deployment remain necessary; see
which security layer to use.
CSRF
Lists write routes (post, put, patch, delete) declared on one line as
method("/path" => handler) or .route("/path", method(handler)). Without the
Server baseline and without csrf_middleware on the router, each write route
is a finding, except the exact paths in [security] csrf_signed_webhook_paths.
Machine endpoints (with_machine_endpoints) are noted in the evidence. Maps to
V3.5.1 (L1).
Session and auth cookies
Reads string literals shaped like a Set-Cookie value (name=value; Path=/):
each needs SameSite, Secure (or a format placeholder that adds it outside
development) and, for session, auth or token cookies, HttpOnly. Cookie
builders with .secure(false), .http_only(false) or SameSite::None, and
[security] csrf_same_site = "None", are findings. Maps to V3.3.1 (L1) and
V3.3.2 and V3.3.4 (L2).
Rate limiting
Finds write routes whose path names an authentication action (login,
register, password, otp, session, token, auth, …). Each one is a
finding when the project mentions no limiter or login jail (RateLimiter,
rate_limit_middleware, Server::rate_limit, RedisRateLimiter,
LoginGuard). The evidence is project-wide: it does not prove each route sits
behind the limiter. Maps to V6.3.1 (L1).
Committed secrets
Scans the files git ls-files lists (target/, symlinks, binaries and files
over 2 MiB are skipped) for private-key headers, AWS AKIA key IDs, Stripe
sk_live_, GitHub ghp_/github_pat_ and Slack xoxb-/xoxp- tokens, and
*_SECRET=/*_KEY= assignments with values of 20 characters or more in a
committed .env file (.env.example-style templates excluded). A finding
shows the file, the line and a redacted preview: the first four characters and
…. The report never contains the value. Untracked files and Git history are
not scanned. A finding means: remove the value, rotate it at the provider and
load it from the environment or a secret manager. ASVS 5.0.0 has no Level 1
requirement for this; it relates to V13.3.1 (L2) in chapter V13 Configuration.
Vulnerable dependencies
Runs the same cargo audit step as cargo rullst audit, with the same
--audit-ignore exceptions, on the project’s Cargo.lock or, in a workspace
member, on the workspace root’s lockfile. Without cargo-audit the check is NOT CHECKED;
a run that reports advisories or fails is ERROR. Maps to V15.2.1 (L1).
IDOR
Reuses cargo rullst audit --idor: every parameterized route needs an
adjacent // rullst-access: public|owner|role|admin — reason classification
and the matching guard in the crate. Maps to V8.2.1 and V8.2.2 (L1).
Personal-data inventory
Lists model fields marked with the ORM attributes: #[privacy] (or
#[privacy(...)]) in a #[derive(PersonalData)] model is personal,
#[orm(encrypted)] or a SecretString type is encrypted, and
#[orm(masked)] is masked. Each row says whether the column is stored
encrypted. Fields of ORM models with likely personal names that carry none of
these markers (email, phone, cpf, cnpj, ssn, birth, address,
document, ip) are listed as review. That is a name heuristic: it
neither proves nor rules out personal data. The inventory is OBSERVED and
never changes the exit status. It relates to chapter V14 Data Protection
(V14.1.1, L2).
Accessibility
A tag scanner reads html! invocations in production Rust sources and HTML
templates (.html, .htm, .tera, .hbs, .jinja, .j2) under src/ and
templates/:
| Check | Finding | WCAG 2.2 |
|---|---|---|
| Images have a text alternative | <img> without alt | 1.1.1 Non-text Content |
| Form controls have a label | <input> (except hidden, submit, button, reset, image), <select> or <textarea> without <label for>, a wrapping <label>, aria-label or aria-labelledby | 1.3.1, 3.3.2, 4.1.2 |
| Pages declare their language | <html> without lang | 3.1.1 Language of Page |
The scanner does not render templates or follow includes, so a label supplied by an included partial is reported for review. Passing these checks does not make a page accessible; test with a screen reader and keyboard.
NOT EVALUATED
ASVS 5.0.0 has 70 Level 1 requirements. The checks relate to 7 of them. The
report lists the other 63 as NOT EVALUATED, by chapter (whole chapters such
as V1 Encoding and Sanitization or V11 Cryptography, and the remaining
requirements of partly mapped chapters). NOT EVALUATED is not a failure and
not a pass: a static source scan cannot establish those requirements, which
need design review, dynamic testing or deployment evidence.
Using the report with an auditor
- Run the report from a clean checkout of the commit under review, with the
same
--audit-ignoreexceptions your CI uses, and keep the JSON next to the commit hash. - Give the auditor the HTML or Markdown file together with the code. Treat
every
FINDINGSrow as a work item and everyNOT CHECKEDorNOT EVALUATEDrow as scope the auditor still has to cover. - Use the JSON (
rullst.cli-audit-report.v1) to track results across runs or to feed a ticketing system; its keys are stable within the schema version. - Do not present the report as a certification or as ASVS compliance. It is one input to the audit, not its result.
External audit kit
This page is for a third-party reviewer or penetration tester. It says what is in scope, where the threat models are, how to build and run a sample application with the production security baseline, which evidence the tooling produces, what to test first and how to report findings. Every command below runs from a checkout of the commit under review.
Scope
In scope
| Area | What to review |
|---|---|
rullst-core | The runtime baseline in rullst-core/src/security/: secure headers and CSP nonce, WAF, double-submit CSRF, exact machine endpoints, trusted-proxy client resolution, tenant context; the Server composition in rullst-core/src/server/stack.rs; rate limiting (resilience_rate_limit.rs); uploads (uploads.rs). |
rullst-security | RASP, response DLP, honeypot, CSWSH guard, JSON schema guard, login jail, MFA, RBAC (RbacGuard, UserContext), audit chain and field encryption. |
rullst-auth, rullst-connect | Password hashing, encrypted sessions, session registry, passkeys, OAuth2/OIDC flows. |
rullst-orm | Parameterized queries, sanitize_identifier, fail-closed tenant columns. |
rullst-capital | Payment webhook signature, freshness and replay verification. |
rullst-nexus, rullst-studio | The Nexus administrator boundary (loopback in debug builds, Basic Auth over verified TLS in release) and that Studio stays a debug-only, loopback-only tool. |
rullst-macros | Output escaping of html!. |
cargo-rullst starters | The code cargo rullst new generates for the Blank, API and SaaS starters, and the audit command. The LMS, ERP and Portfolio applications (former starters) are workspace examples under examples/. |
Endpoints worth a direct look in the starters: SaaS /login, /register,
/logout, /dashboard, /billing/checkout, /billing/portal,
/billing/webhook and /nexus; LMS /courses/{id}/enroll,
/lessons/{id}/play and /lessons/{id}/progress; ERP /products,
/products/{id}/add-stock and /orders; Blank JSON API POST /api/messages.
In the sample application: POST /posts, /posts/repository, /checkout,
/security-demo, /_live (WebSocket), /nexus and the /wp-admin trap.
Out of scope
- Live third-party services: payment, mail, LLM and OAuth providers. The framework ships deterministic offline mocks for them; test the adapter boundary, not the provider.
- Your deployment: reverse proxy, TLS termination, DNS, host and network hardening, and volumetric DDoS.
- The deliberate mock credentials and offline fixtures of
examples/(seeexamples/blog/README.md). - The Experimental-tier crates added in v13 (
rullst-privacy,rullst-labs,rullst-media,rullst-supervision; see maturity tiers) unless the engagement names them.
Threat models
- Rullst threat models: one model per boundary. Start with TM-CORE-2 (client identity behind proxies), TM-AUTH-1 (sessions, passwords, OAuth and passkeys), TM-NEXUS-1, TM-STUDIO-1, TM-TENANT-1 (multi-tenant data access), TM-SEC-1 (HTTP payload contracts), TM-PAY-1 (webhooks and billing) and TM-DEPLOY-1 (CLI and release).
- Security architecture: contracts and the canonical production middleware order.
- Which security layer to use: what each layer does and what it does not protect against.
- Session management, private multipart uploads, outgoing webhooks and guarded local AI tools for those features.
- v12 security claims and
.github/threat-model-release-minimum.json, the release gate’s minimum threat-model evidence.
Sample application
Showcase app (examples/blog)
The blog showcase mounts CSRF and the header baseline itself and exercises
tenancy, forms, WebSockets, Nexus and the security primitives. Run it with
RULLST_ENV=staging, which applies the same baseline as production (WAF,
CSRF, secure cookies), and keep it on loopback:
cd examples/blog
printf 'RULLST_ENV=staging\nAPP_KEY=%s\n' "$(openssl rand -hex 32)" > .env
HOST=127.0.0.1 cargo run -p rullst-blog-example
It listens on http://127.0.0.1:3000 and creates blog.db on first start.
Quick checks from another terminal:
# Security headers and the CSRF cookie
curl -si http://127.0.0.1:3000/ | grep -iE 'content-security-policy|x-frame-options|set-cookie'
# A write without the CSRF token is refused (403)
curl -si -X POST http://127.0.0.1:3000/posts --data 'title=t&body=b' | head -1
# The WAF refuses an injection probe (403)
curl -si 'http://127.0.0.1:3000/?q=1%27%20union%20select%20pw%20from%20users--' | head -1
A debug build keeps Nexus loopback-only. To review the release policy, build
with --release and provide unique NEXUS_ADMIN_USERNAME and
NEXUS_ADMIN_PASSWORD values behind TLS, as the example’s README explains.
Generated starters
Build the CLI from the checkout and generate a starter next to it. Run the command from the repository root, so the project depends on this checkout’s crates through path dependencies:
cargo build -p cargo-rullst --bin rullst
./target/debug/rullst new ../audit-saas --default --blueprint saas --database sqlite --skip-initial-migration
cd ../audit-saas
cargo test
Use --blueprint blank or api for the other starters; the LMS, ERP and
Portfolio examples run their tests with cargo test -p rullst-lms-example (or
-erp-, -portfolio-) from the repository. cargo test runs the generated src/security_tests.rs
offline against the starter’s own router behind the staging/production
baseline: security headers, CSRF, WAF, and where the starter has them, the
sign-in rate limit (SaaS, LMS), the owner check of the lesson routes (LMS) and
the bearer token of the JSON API (--blueprint api). Treat these as a floor, not
as your test plan.
Tooling
Run the evidence report from the application directory. The CLI built above works in any project:
cd examples/blog
../../target/debug/rullst audit --report json # writes SECURITY_REPORT.json
In a generated project, run ../Rullst/target/debug/rullst audit --report json
(adjust the path to your checkout), or cargo rullst audit --report json with
an installed CLI. The security report guide explains
each check, its OWASP ASVS 5.0.0 Level 1 mapping and the NOT EVALUATED
list. The command exits 1 when a check reports FINDINGS or ERROR.
The SBOM and supply-chain evidence come from the repository root, as the
release workflow (.github/workflows/release.yml) produces them:
cargo run --locked -p cargo-rullst --bin rullst -- audit --idor --compliance --sbom
cargo audit
The first command writes sbom-cyclonedx.json (CycloneDX 1.5, from
Cargo.lock) and SECURITY_COMPLIANCE.md; cargo audit needs cargo-audit
installed. Dependency policy is in deny.toml, which CI checks with
cargo-deny (.github/workflows/supply-chain.yml); locally, cargo deny check.
What to test first
- Authentication and sessions: login, registration and logout in the SaaS
starter and the LMS example; session cookie attributes and fixation; the credential
rate limit and its keying behind a proxy (
[security] trusted_proxies); passkeys and OAuth state inrullst-authandrullst-connect. - CSRF: every write route in staging, including multipart bodies (the
token must precede file fields), HTMX requests and the exact
csrf_signed_webhook_pathsand machine-endpoint exemptions. - Tenant isolation:
TenantContext, fail-closedtenant_columnmodels (the showcase’sX-Tenant-IDselector and/posts/repository) and Nexus tenant columns. - IDOR: parameterized routes such as the LMS lesson and enrollment routes
and ERP stock updates.
cargo rullst audit --idorfails on a route without a declared access class (// rullst-access: ...) and a recognized guard; verify that the guard actually protects the route. - Webhooks: signature, freshness and replay checks on
/billing/webhookand inrullst-capital; the bearer machine endpoint of the JSON API. - File upload: body limits, content-type handling, file-name sanitizing and the private multipart upload flow.
- WAF and RASP bypass: encodings, payloads split across fields, JSON versus form bodies, and false positives on ordinary text. The WAF is a coarse baseline; parameterized SQL and output encoding are the defenses.
Reporting
Report vulnerabilities privately; never in a public issue or pull request:
- GitHub private vulnerability reporting: the Report a vulnerability button on the repository’s Security tab; or
- e-mail to
[email protected], as the security policy describes, with the affected crate and version, a proof of concept and the estimated impact.
Include the commit hash you reviewed and, when relevant, the
SECURITY_REPORT.json and the commands you ran.
What a clean result means
A run with no findings means that the checks you ran saw nothing they recognize on that commit and configuration. It does not mean:
- that the application is secure, or that a requirement such as an OWASP ASVS
control is met:
audit --reportnever reportsPASSand lists most Level 1 requirements asNOT EVALUATED; - that a deployment is safe: proxy, TLS, secrets and host configuration are outside the source;
- that authorization is correct: the IDOR scan checks declarations and guards, not business rules;
- that a later commit, another feature set or another starter behaves the same.
Record the scope, commit, configuration and commands with every result, and
treat each NOT CHECKED or NOT EVALUATED item as work the review still has
to cover.
Rullst threat models
Model version: TM-13.0 Applies to: the v13 development source and generated applications; the frozen v12 branch (end of life since 16 October 2026) keeps TM-12.10 Last source review: 2026-09-03 Status: maintainer baseline; application owners must extend it for their data, topology and providers. It is not a pentest or certification.
These models turn security ambitions into named abuse cases. A control is not effective merely because its type exists: it must be mounted in the deployed application and its negative case must pass. The security architecture defines the canonical HTTP boundary and the v12 release audit records repository evidence.
Method and common boundaries
The method is a lightweight STRIDE review: spoofing, tampering, repudiation, information disclosure, denial of service and elevation of privilege. Every model distinguishes untrusted network input, authenticated application identity, process-local state, shared durable state and third-party/hardware trust.
Process-local replay caches, counters, rate limits and audit buffers are useful
single-instance controls. They are not distributed guarantees. Forwarded
addresses are untrusted unless an explicit proxy policy (Core’s
TrustedProxyLayer, see CORE-03) establishes the direct peer as trusted.
The machine-readable release minimum in
.github/threat-model-release-minimum.json binds 52 distinct abuse-case IDs to
70 evidence rows and 69 exact test executions across fourteen packages
(thirteen crates and the LMS example, rullst-lms-example). The gate
rejects missing markers, missing tests and zero-test filters before executing
Core (including trusted-proxy client resolution), ORM, Auth, Connect, Security,
Messaging, Nexus, Studio, the production monitor, tenant ownership, Capital, AI, Mail, IoT,
generated-default and Academy negatives. Passing that bounded minimum does not
imply that every case below is closed.
TM-CORE-1 — readiness and graceful request drain
Assets: service availability, readiness state, accepted requests and dependency-health observations.
Trust boundaries: orchestrator ↔ health endpoint; application dependency checks ↔ process-local readiness bits; new requests ↔ draining server.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
CORE-01 forged readiness or diagnostic disclosure | Readiness must fail during startup, any required-component failure, corrupted component state and drain; keep liveness process-only and return counts rather than component labels or error details. | The lifecycle-aware HTTP regression crosses startup → ready → draining, proves 503/200 transitions and verifies that the private component label is absent. Applications still own bounded dependency probes and determine which failures should gate traffic. |
CORE-02 shutdown race admits new work or abandons accepted work | Change admission monotonically to draining before the server’s graceful wait, reject later requests and bound the operator’s wait for already accepted requests. | The concurrency regression holds one admitted request, begins drain, rejects a second request with 503, observes a typed timeout, releases the first and reaches zero. A real ephemeral-listener test proves the Server ready → shutdown → stopped lifecycle. The v13 candidate additionally retains admission through response data/trailers and proves streaming drain with two real processes and an existing proxy; hosted admission is pending. Load-balancer propagation, client retry/idempotency, supervisor kill deadlines and multi-replica coordination remain deployment/application work. |
Release-negative minimum: unready/draining response without private labels; request accepted before drain completes while a later request is denied.
TM-CORE-2 — client identity behind reverse proxies
Assets: rate-limit, lockout, ban and audit identity; the TLS evidence used by Nexus Basic Auth; availability of clients that share a proxy.
Trust boundaries: arbitrary client ↔ forwarding headers; socket peer ↔ configured proxy networks; trusted proxy ↔ its appended entries.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
CORE-03 forged forwarding metadata chooses or collapses the client identity | Read X-Forwarded-For or RFC 7239 Forwarded (exactly one) only from a socket peer inside a bounded, validated list of trusted networks; never trust /0 or an empty policy. Walk right to left, skip trusted hops, bound examined bytes/hops and keep the socket peer without failing the request when the trusted part is missing, malformed, unidentified or over-long. Report the forwarded scheme only after explicit opt-in. | TrustedProxyLayer unit tests cover direct-client spoofing, multi-hop chains, IPv6/brackets/ports/IPv4-mapped forms, unknown/obfuscated nodes, quoted-comma injection, byte/hop limits and client padding that is never parsed. Router tests prove Core RateLimiter, Security rate_limit_middleware and Nexus Basic Auth lockout bucket each client behind a trusted proxy while forged headers from a direct client are ignored, that only a trusted peer’s https report satisfies Nexus TLS evidence, and that Server mounts the layer outside its rate limiter (static and hot-reload paths). A trusted proxy that forwards client-supplied headers unchanged, proxy address spoofing on a shared network, CDN/provider range maintenance and distributed limits remain deployment work. |
Release-negative minimum: a direct client forging forwarding headers keeps its socket identity, and two clients behind one trusted proxy receive separate rate-limit buckets.
TM-AUTH-1 — sessions, passwords, OAuth/OIDC and passkeys
Assets: password verifiers, session keys/tokens, OAuth state and nonce, passkey challenges/credentials, recovery paths and account identity.
Trust boundaries: browser ↔ application; application ↔ OAuth/OIDC provider; application ↔ session/challenge store; operator ↔ key store.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
AUTH-01 forged, truncated, expired or legacy session | Reject before constructing identity; authenticate encryption version, expiry and payload. | Negative session parsing/encryption tests in rullst-auth. |
AUTH-02 weak/missing application key | Fail startup/configuration closed outside explicit deterministic mocks. | Weak-key and malformed-key tests. Malformed .env and Rullst.toml errors carry no file content (.env names an entry number, Rullst.toml a line and column), and cookie helpers skip .env when the process selects the environment; isolated-process regressions cover each. |
AUTH-03 password timing/CPU starvation | Use Argon2id off the async executor, bound input and normalize failure behavior. | spawn_blocking implementation and password tests. The recovery registry applies the Argon2 72-byte bound before account lookup, so overlong input yields one error for registered and unknown emails; a regression covers login, registration and reset. Deployment capacity remains application work. |
AUTH-04 OAuth login CSRF/code substitution | Bind state, nonce, redirect URI, issuer, audience and one-time callback context. | The optional Axum/tower-sessions path generates a ten-minute state/PKCE challenge plus OIDC nonce, stores verifier/nonce server-side, removes and immediately saves the one active challenge before validation, and tests sequential replay, mismatch, expiry, replacement and redaction. The v13 prerelease AuthSessionForm applies the same consumption to a bounded form POST body for response_mode=form_post providers such as Apple and tests mismatch, replay, method, media-type and size rejection. The store trait does not provide distributed compare-and-delete across already-loaded requests. The v13 prerelease challenge store keeps the same challenge in the application’s database (or memory) under a digest of an opaque 256-bit id that is the only value in a short HttpOnly, SameSite=Lax cookie, and consumes it with one atomic removal: tests cover replay, expiry, wrong and missing state, missing and altered cookies, another provider’s challenge, a provider error with a forged state, and concurrent callbacks with exactly one winner (in memory and on SQLite; PostgreSQL and MySQL run against owned servers in CI). `cargo rullst make:oauth google |
AUTH-05 passkey origin/RP/challenge confusion | Require exact RP/origin, ceremony type, single-use bounded challenge, UV/UP flags and supported attestation/key formats. | Negative tests cover the bounded ES256/none scope. Challenge lifetimes are capped at one day and expiry arithmetic is checked, so configuration cannot panic and poison the challenge store. Normative WebAuthn conformance remains open. |
AUTH-06 session fixation/replay/revocation gap | Rotate on privilege change, expire server-side and provide device/session revocation. | Versioned expiring cookie sessions exist. The optional JWT policy uses bounded expiry, kid rotation, token IDs and subject session versions; production rejects its process-local store. The SQLite profile adds shared-local JTI/session-version revocation and bounded passkey inventory/revocation with counter CAS. The optional v13 shared PostgreSQL ceremony candidate adds account/session/RP-bound single use with local database/browser evidence; its monotonic shared clock lets a host up to five seconds behind adopt the recorded time and fails closed beyond that. Hosted admission remains pending. Cookie-session inventory, refresh flow, credential-repository replication/failover and application device ownership remain open. |
AUTH-07 account enumeration/recovery takeover | Normalize public responses, rate limit attempts, protect recovery factors and audit changes. | Login jail/timing helpers and subject-bound 80-bit recovery-code verifiers exist. Plaintext is returned only at enrollment and zeroized on drop; comparison is constant-time and consumption removes the verifier. Durable transactional consume, enrollment UX, step-up policy and full recovery workflow remain application-owned. |
Release-negative minimum: malformed/expired session, wrong key, cross-origin passkey, replayed challenge, wrong RP, invalid callback state and repeated login failure.
TOTP replay (AUTH-07): verify_totp_code is stateless and accepts the
previous, current and next 30-second step, so an observed code verifies again
for about 90 seconds. verify_totp_step_after accepts only a step newer than
the supplied last accepted step (RFC 6238 section 5.2); persisting the returned
step per secret atomically before admitting the login is application-owned.
TM-CONNECT-1 — durable local OAuth token generations
Assets: access and refresh tokens, provider identity, application-account binding, encryption keys and the latest accepted token generation.
Trust boundaries: application authorization ↔ account binding; provider response ↔ refresh coordinator; local processes ↔ shared SQLite state; and operator secret manager ↔ encrypted snapshot key.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
CONNECT-24 stale or competing local writer rolls a rotated token generation back | Store only authenticated ciphertext plus non-secret key/generation metadata, serialize local writes and replace/delete only after an exact generation compare-and-swap. Reject configuration drift, quota exhaustion, malformed rows and unsafe existing file targets without exposing the database path or token material. | The opt-in SQLite store uses BEGIN IMMEDIATE, an immutable persisted row ceiling and exact successor CAS. Two independent pools race generation one; exactly one succeeds, the loser fails with GenerationConflict, restart recovers the winner, and a stale delete is rejected. Separate negatives cover key mismatch, ciphertext authentication, metadata corruption, quota/configuration, plaintext absence and symlink targets. Provider-call leases, recovery after a losing remote refresh, key-manager operation, trusted directory permissions, backup, multi-host replication and live-provider conformance remain application/deployment work. |
Release-negative minimum: two local writers cannot both replace one observed generation, and restart cannot turn a stale generation into the winner.
TM-NEXUS-1 — administrative CMS
Assets: administrative session, model records, bulk actions, AI assistant tools, audit evidence and security telemetry.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
NEXUS-01 anonymous/default access | Fail closed without explicit production policy. Local shortcut requires debug build and verified loopback peer. | Router policy and loopback tests. Basic Auth counts only failed presented credentials per IPv4 address or IPv6 /64; credential-less challenges never lock. A locked bucket is not evaluated for unknown clients, while browsers holding the per-process known-client cookie keep access, and pruning is amortized. Behind a shared proxy a first login can still be locked out unless Core’s TrustedProxyLayer (CORE-03) is configured with the proxy’s network; a router test then proves per-client lockout isolation. |
NEXUS-02 IDOR/BOLA on CRUD/batch routes | Resolve subject/tenant, then authorize object ownership or role for every ID and bulk member. | Models that explicitly register a text tenant column now scope every built-in read/mutation/batch predicate to a trusted TenantContext, inject it on create and fail closed without context. A real SQLite HTTP regression proves cross-tenant list/update/delete/batch denial and protected create input; pure SQL tests keep the all-feature release minimum portable. #[derive(Nexus)] keeps ORM-encrypted, SecretString and #[orm(hidden)] fields hidden and read-only, shows #[orm(masked)] fields only as write-only Password widgets by default and omits skipped fields, so the panel never lists, searches or overwrites them with plaintext; derive unit and facade regressions cover this. Global models, custom routes, identity/membership resolution, within-tenant object ownership and independent review remain host work. |
NEXUS-03 stored/reflected XSS | Escape dynamic HTML; make raw HTML explicit; enforce nonce CSP. | Core proves renderer/header nonce identity. Generated LMS auth/catalog/course/player style elements consume the request nonce and remove remote shell dependencies/inline style attributes. Nexus serves its stylesheet, script and vendored htmx same-origin with no inline code, handler or style attributes; an HTTP regression checks every Nexus page and fragment against the default production CSP, and the Linux browser check loads the shell under that CSP and fails on any violation. A route-by-route review of application routes mounted next to Nexus remains open. |
NEXUS-04 AI assistant privilege escalation | Treat model output as untrusted; allowlist typed tools and authorize each invocation as the human subject. | Prompt filtering exists; tool approval/audit policy remains open. |
NEXUS-05 destructive CSRF | Require CSRF on cookie-authenticated mutations and exact signed-webhook exemptions only. | The exact Core baseline regression proves a production cookie write is denied without the matching double-submit value, accepted with it and retains the outer header/CORS policy on denial. A nested-layer regression proves an explicitly protected application router wrapped by the Server baseline emits one matching token/cookie pair and accepts the valid POST instead of applying the protocol twice. Exact signed-webhook exemptions have separate unit negatives; a full Nexus browser/proxy flow remains open. |
NEXUS-06 audit repudiation | Record actor, tenant, object, operation, outcome and correlation ID in durable separate storage. | The opt-in required policy records the built-in authenticated actor, optional tenant, table/action, optional known key, count, committed outcome, bounded correlation ID, timestamp and format version in the same transaction; unavailable storage rolls the mutation back. A real SQLite regression covers commit and rollback, while schema tests cover all SQL dialects. It is same-database mutable evidence, not separate append-only or tamper-evident storage; denied attempts and automatically assigned create keys are not uniformly persisted. Host retention, backup, replication, immutable export and review remain open. |
Trust boundaries are browser ↔ Nexus, Nexus ↔ application policy/database and Nexus ↔ LLM/provider.
TM-STUDIO-1 — local developer control room
Assets: environment/configuration, logs, traces, database browsing, job controls, prompts and source-error tooling.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
STUDIO-01 remote exposure | Compile/mount shortcuts only in debug development and bind loopback. Server mounts the panic console, /_rullst/explain, the retired /_rullst/autofix (410 Gone, never edits files) and /_rullst/errors/{id} only when the build has debug assertions and the environment is Development, so a release binary with an unset environment does not expose them; the panic console renders details only to loopback peers, and /_rullst/errors/{id} serves the bounded, expiring, header- and body-free panic context (whose path the page’s “Ask your AI assistant” panel shows for the developer’s own assistant) only to direct loopback requests with a loopback Host and no forwarding headers. Production exposure needs application-owned auth and TLS. | Generated startup and Studio boundary tests/docs; builder console-gate, panic-console peer and error-context endpoint tests. |
STUDIO-02 secret leakage | Mask environment/log fields by key and value patterns; never render raw credentials. | Environment viewer/redactor tests cover key markers plus URL user information, bearer tokens and secret-named assignments in allowlisted values; novel formats remain residual risk. |
STUDIO-03 SQL/table injection | Parameterize values and strictly validate dynamic identifiers. | Data-browser identifier/query-builder tests. |
STUDIO-04 arbitrary source file access | Require debug loopback, canonical allowlisted paths/extensions and bounded files. | Traversal, sensitive-file, non-loopback and extension tests. |
STUDIO-05 forged telemetry | Label local/unverified events accurately; never invent source IP, HMAC verification or provider status. | Telemetry integrity tests. |
STUDIO-06 destructive job/database action | Require verified local policy and same-origin checks; remote production controls remain disabled until separately authenticated, authorized and reviewed. | Studio serves Referrer-Policy: same-origin, so browser form posts carry the real origin; Origin: null passes only with Sec-Fetch-Site: same-origin. A browser-modelled regression covers Studio’s served policy, an imposed no-referrer, cross-site and same-site (other local port) documents and a bare null origin; a local Chrome run confirmed the header values. Data-browser writes additionally require an unforgeable middleware marker, inspected complete PK, typed binds, exactly one affected row and exact delete confirmation. A key column outside the identifier boundary or column cap makes the table read-only, and the write transaction is rolled back unless exactly one row changed; a four-backend composite-key regression and a multi-row rollback test cover this. Importing the raw router is denied by an exact release-negative test. Queue retry/purge and feature-flag toggles require the same marker; raw-router negatives prove that they return 403 and leave queue records and flag values unchanged. Because safe methods pass without an Origin, GET views perform no schema or data writes; a four-backend regression proves that the feature-flag page no longer creates rullst_feature_flags. Application tenant/RBAC, durable audit and rollback remain open. |
STUDIO-07 forged or replayed remote span batch | Keep ingestion push-only; bind each endpoint to one producer name/key, authenticate the exact body and bounded source/timestamp/nonce fields, consume valid nonces atomically, reject stale/future requests and validate the complete batch before storage. | Wrong-source/key, HMAC tamper, stale timestamp, concurrent replay, schema/cardinality, deduplication and capacity regressions. TLS, key custody/rotation, clock synchronization and producer admission remain deployment work. |
STUDIO-08 cache secret exposure or broad deletion | Return metadata only, replace logical keys with keyed opaque browser tokens, omit values, omit bulk flush and require the verified-local mutation marker for one-entry invalidation. | Memory plus live Redis metadata contracts, HTML non-disclosure tests and forged/missing-marker mutation negatives. Application cache-key classification and operator policy remain external. |
STUDIO-09 database selection drift | Reuse the application’s ORM pool; otherwise resolve the database only through the shared Server/Artisan resolver, never invent a fallback database and never echo configuration content in errors. | Resolver unit tests pin the process-environment, .env and [database].url precedence, full query strings and content-free errors; an isolated-process regression proves that an unconfigured GET /studio creates no pool and no db.sqlite. A Studio request that arrives before an application’s own explicit Orm::init can still initialize the pool first; applications that choose their URL in code should initialize it before serving Studio. |
STUDIO-10 resource exhaustion through table views | Bound rows, cell text and search terms before data leaves the database or is bound per column. | Pages load 25 rows; the SELECT cuts cell text to 256 characters (key columns to 16 KiB plus one character, making longer keys read-only) and search terms above 256 bytes return 422. A four-backend regression proves that an 8 KiB cell is cut and the search bound is enforced. The per-column LIKE scan remains proportional to table size. |
Trust boundaries are local browser ↔ loopback listener, authenticated trace producer ↔ push-only application endpoint, Studio ↔ telemetry/database/cache, and error console ↔ source filesystem.
TM-STUDIO-2 — production monitoring endpoint
Added for 13.0.0-alpha.3, before the endpoint shipped. Phase 1 of the
Studio production monitor plan
is a read-only JSON endpoint, GET /_rullst/monitor/v1, that an application
mounts with Server::with_monitor. The standalone Studio that reads it for
several projects from the developer’s machine (phases 2 and 3) is modelled in
TM-STUDIO-3.
Assets: operational metrics of a production process (request counts, rates and latency per route pattern, status classes, queue depth and worker activity, possible N+1 query fingerprints, process radar); the monitor tokens; the availability of the application that serves the endpoint.
Actors: an anonymous internet client; the holder of a leaked or replayed monitor token; a user signed in to the application without the monitoring permission; a compromised developer machine that holds tokens for several projects; a browser page on another origin using a signed-in administrator’s cookies; a network observer between the client and the TLS terminator.
Trust boundaries: internet client ↔ reverse proxy/TLS terminator ↔ application listener; monitor client ↔ token or application authorizer; request handling ↔ the bounded in-process collector; collector ↔ queue store.
Test files named below are under rullst-core/src/.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
MONITOR-01 endpoint exposed by default or by accident | Off by default. The route and the recording layer exist only when the application calls Server::with_monitor and at least one credential source is configured (a token from RULLST_MONITOR_TOKENS or code, or an application authorizer); otherwise the path answers 404 like any unknown route and request handling is unchanged. Generated starters do not enable it: the Blank, SaaS and LMS starters carry the with_monitor call and the RULLST_MONITOR_TOKENS setting commented out. | server/monitor_stack_tests.rs: no monitor and a monitor without credentials both answer 404, and an invalid token stops start-up without echoing it; monitor/endpoint_tests.rs: Installed::prepare mounts nothing (no route, no recording layer) without a credential, including an empty RULLST_MONITOR_TOKENS list. |
MONITOR-02 token guessing, theft and replay | Accept tokens only in Authorization: Bearer (never in the query string, which access logs and proxies record). Reject at startup tokens below 32 bytes of entropy (43 base64 or 64 hex characters, a minimum character variety, no placeholders) without echoing them. Compare against every configured token in constant time; several tokens allow rotation without downtime. Count only presented, invalid tokens as failures; after a bounded number per client (IPv4 address, IPv6 /64, resolved through CORE-03 trusted proxies) answer 429 without evaluating credentials until the bucket refills. Missing and invalid credentials get the same 401. A bearer token is replayable by design: TLS (MONITOR-08), rotation and the read-only scope bound the damage; there is no nonce or expiry in phase 1. | monitor/token_tests.rs (length, alphabet, variety, placeholders, list size, position-only errors, constant-time set matching, Authorization parsing); monitor/endpoint_tests.rs for missing, malformed, wrong, other-scheme, rotated and valid tokens with one identical 401, and a lockout that answers 429 even to a valid token while other clients are unaffected. Distributed (multi-replica) lockout and token expiry remain deployment work. |
MONITOR-03 signed-in user without the monitoring permission | With an application authorizer, the application decides on every request: Allow serves, SignIn answers 401, Deny answers 403, and an authorizer error fails closed with 503 and a logged reason. A token never widens what the authorizer denies for other requests. | monitor/endpoint_tests.rs: authorizer allow, sign-in, deny and error, a bearer value that is not a token, and token or authorizer either sufficing. |
MONITOR-04 information disclosure through the payload | Report only aggregates keyed by the matched route pattern (/users/{id}), never the raw path, query string, headers, cookies, client addresses, user identifiers, request or response bodies, SQL text or bound values, error messages, environment or configuration values. Requests without a matched route are counted as (unmatched). N+1 findings carry the ORM’s static Model.operation (table) fingerprint. Queue probe failures report a fixed reason, never a driver message. The schema is versioned (rullst.monitor.v1). | monitor/payload_tests.rs pins the exact set of JSON fields and values; monitor/record_tests.rs sends a path with an id, an email and a query string, a cookie, an Authorization value, a forwarded address and a user header, and finds only the route pattern in the body; monitor/config_tests.rs proves a driver error message never reaches the document. Application-chosen route templates and queue names are the application’s responsibility. |
MONITOR-05 denial of service through the endpoint or through collection | Bound collection: at most 256 route patterns (further ones share an (other) bucket), methods normalized to the standard set plus OTHER, a fixed latency histogram over a five-minute window, at most 64 N+1 entries, 16 queues and a 250 ms limit per queue probe. Collection is O(1) per request and lock-local to the route. The endpoint has its own per-client rate limit, and its requests are not counted in the statistics. The overhead per request is measured and published in the guide. | monitor/collector_tests.rs (route and method cardinality, (other), N+1 table eviction), monitor/endpoint_tests.rs (per-client budget), monitor/config_tests.rs (concurrent queue probes bounded by the timeout), monitor/record_tests.rs (own requests not counted); the release-mode overhead measurement (monitor/overhead_tests.rs) is published in the production monitoring guide. |
MONITOR-06 cache or proxy keeps a response | Every monitor response, including refusals, carries Cache-Control: no-store and X-Content-Type-Options: nosniff; staging and production also receive the security-header baseline. | monitor/endpoint_tests.rs checks the headers on 200, 401, 403, 405, 426 and 429; server/monitor_stack_tests.rs checks the production security headers. |
MONITOR-07 cross-origin read with an administrator’s ambient cookies | The endpoint is a machine contract: a request carrying an Origin header, or a Sec-Fetch-Site of cross-site or same-site, is refused with 403 before credentials are evaluated, so even an application CORS policy that allows the origin never releases monitoring data to a page. The endpoint itself adds no CORS headers and serves application/json with nosniff. | monitor/endpoint_tests.rs: Origin (including null) and cross-site or same-site Sec-Fetch-Site refused with an empty body and no Access-Control-Allow-Origin from the endpoint; server/monitor_stack_tests.rs: refused under an application CORS allowlist that includes the origin. |
MONITOR-08 downgrade to plain HTTP | In staging and production, refuse (426 Upgrade Required, before credentials are evaluated) a request without TLS evidence: an https scheme reported by a trusted proxy with trust_forwarded_proto (CORE-03), or a MonitorVerifiedTls marker inserted by an application that terminates TLS itself. A forwarded header from an untrusted peer and an absolute-form https:// request target are not evidence. An explicit opt-in admits plain HTTP from a direct loopback peer without forwarding headers (an SSH tunnel or a sidecar on the same host). A token already sent in cleartext must be rotated. | monitor/endpoint_tests.rs: staging and production plain HTTP and an absolute https:// target refused; forged X-Forwarded-Proto from an untrusted peer refused; trusted proxy https admitted and http refused; marker admitted; the loopback opt-in admits direct loopback peers and refuses forwarding headers, a public Host and other peers; development and test admit plain HTTP. |
MONITOR-09 compromised developer machine or monitoring client | A token grants read-only access to one application’s aggregates and nothing else: no rows, no secrets, no mutation, no Studio development pages. Use one token per project and client, so revoking one machine is removing its token. | The read-only scope follows from MONITOR-04; monitor/endpoint_tests.rs proves other methods answer 405. Tokens stored on a developer machine are covered by REMOTE-03 (TM-STUDIO-3). |
MONITOR-10 monitoring changes or breaks request handling | The recording layer never alters a response; it observes method, matched route, status and duration, tolerates poisoned locks and adds no work when the monitor is disabled. ORM attribution uses the passive query layer the telemetry subscriber installs; with a RUST_LOG that filters rullst_orm spans or an application-owned subscriber, the payload says so instead of reporting zero queries. | monitor/record_tests.rs compares responses with and without the layer and counts ORM operations only inside a recorded request; monitor/payload_tests.rs covers the unavailable reasons; the full Core suite passes with the passive layer. |
Release-negative minimum: disabled → 404; missing and invalid tokens →
the same 401; production plain HTTP → 426; a raw path with an id and a
query string never appears in the payload.
TM-STUDIO-3 — Studio remote projects on the developer’s machine
Phase 2 of the
Studio production monitor plan:
the standalone rullst-studio binary runs on a developer’s machine without an
application process, keeps a list of projects (name, base URL, monitor token)
and renders each project’s rullst.monitor.v1 document (TM-STUDIO-2). It
connects outwards only; the server collects nothing new. Studio’s local pages
(data browser, .env viewer, AI and API playgrounds, migrations) are not part
of this mode.
Assets: the monitor tokens of every configured project; the developer’s local network position; the integrity of what Studio displays; the availability of the developer’s Studio process.
Actors: a malicious or compromised monitored application (or anyone able to answer for its origin); a web page open in the developer’s browser; another local user on the same machine; malware running as the developer; a network attacker between Studio and the monitored application; someone who obtains a copy of the configuration file (backup, dotfile sync, screen share).
Trust boundaries: browser ↔ Studio’s loopback listener; Studio ↔ the configuration file and its separate key file; Studio ↔ each monitored origin over the network; Studio ↔ its local history database (phase 3); Studio ↔ the developer’s webhook receiver (phase 3).
Test files named below are under rullst-studio/src/.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
REMOTE-01 Studio reachable from other machines or pages | Bind 127.0.0.1 only; every route keeps Studio’s loopback boundary (loopback peer, loopback Host, same-origin Origin on unsafe methods, the crate-private marker), which remote mode applies in release builds too because it serves no local data. | remote/router_tests.rs: a non-loopback peer, a public Host and cross-origin or Origin-less form posts are refused; bin/rullst-studio.rs: the only option is --port (a --host argument is rejected). |
REMOTE-02 local CSRF against add/remove-project forms | Unsafe methods require a same-origin Origin (as for every Studio form) and a per-process random form token compared in constant time; GET pages change nothing. | remote/router_tests.rs: a post without Origin, with another origin, or with a missing or wrong form token answers 403 and leaves the project list unchanged; form tokens are compared whole. |
REMOTE-03 token disclosure at rest | Store the project list in the user’s configuration directory (0600 file in a 0700 directory on Unix, never through a symlink, written atomically, bounded size). Encrypt each token with AES-256-GCM under a 256-bit key kept in a separate file in the user’s data directory (0600), with the project name and base URL as associated data, so a token copied to another project or a URL edited in the file no longer decrypts and is never sent. Tokens never appear in pages, logs, errors or Debug. This protects a copied configuration file, not a compromised account: malware running as the developer can read both files. | remote/store_tests.rs: encryption round trip, the file never contains the token, swapped names and an edited URL fail to decrypt, a missing key makes tokens unreadable, 0600/0700 permissions and the readable-by-others warning on Unix, symlinked files refused, malformed and oversized files reported without contents. OS keychain integration is not provided. |
REMOTE-04 token sent to the wrong party | Accept only https base URLs, or http to a loopback host (SSH tunnels). Reject user information, query and fragment. Send the token only in Authorization: Bearer to the configured origin’s fixed monitor path; follow no redirects; validate certificates (no option to disable it); ignore proxy environment variables. | remote/store_tests.rs: http to a non-loopback host, user information, query strings, fragments and non-HTTP schemes are refused; remote/client_tests.rs: a redirect to another origin is reported and the second origin never receives the token, and the token goes as Bearer to the configured origin only. |
REMOTE-05 hostile or compromised monitored endpoint | Treat the document as untrusted: bound the response (1 MiB, 5 s total, 3 s to connect), require 200, JSON and schema: "rullst.monitor.v1", bound every list and string, and HTML-escape every remote value; an error shows a fixed status, never the remote body. | remote/client_tests.rs against a local test server (remote/server_test.rs): oversized (declared and streamed), malformed, wrong-schema, non-JSON and slow responses become fixed statuses within the limits; remote/router_tests.rs: script and markup in labels are escaped on every page and unreachable projects show a status. |
REMOTE-06 SSRF-like use of the project list | Projects are added only through the protected form; Studio requests only GET <base>/_rullst/monitor/v1 and renders only the validated schema, so a project pointing at an internal service yields at most a refusal status. At most 32 projects. | Covered by REMOTE-02, REMOTE-04 and REMOTE-05 tests. Network policy on the developer’s machine stays with the developer. |
REMOTE-07 local surfaces leaking into remote mode | The remote router mounts only the remote pages; the data browser, .env viewer, AI and API playgrounds and migration pages are absent. | remote/router_tests.rs: those paths answer 404 in remote mode while the remote pages and assets answer. |
REMOTE-08 history store disclosure or growth (phase 3) | Store only aggregates the document already exposes (reachability, request rate, 5xx rate, p95, queue pending and failed totals) per project and time, never tokens, routes or other labels, in an owner-only SQLite file in the user’s data directory. Delete samples older than the retention age and beyond a per-project row limit on every write; keep a bounded number of alerts. | remote/history_tests.rs: retention by age and by row count, alerts bounded, file permissions on Unix, only numeric columns stored. |
REMOTE-09 polling turned into load or a network scanner (phase 3) | Poll only projects the developer marked as watched, at an interval bounded to 15 s–1 h (default 60 s), one read per project per interval with the REMOTE-04/REMOTE-05 bounds, so the monitored server’s own budget (MONITOR-05) is never approached. | remote/poller_tests.rs: one read per watched project per round, unwatched projects untouched, interval clamped. |
REMOTE-10 alert spam or hostile values in alerts (phase 3) | Evaluate rules with an explicit clock; a rule fires again only after its per-project cool-down; the 5xx-rate rule needs a minimum number of requests; restarts that reset counters never fire the failure rule. Alert text is built from Studio’s own wording, the project name and numbers, never from remote labels. | remote/alerts_tests.rs with a fixed clock: thresholds, cool-down, minimum requests, counter resets, unreachable projects. |
REMOTE-11 webhook secret disclosure or misuse (phase 3) | A webhook URL is a secret: stored sealed like tokens (bound to the project), shown only as configured or not, https:// only, no user information or fragment. Delivery sends a small JSON body with the project name, rule, message and time, follows no redirect, ignores proxies, is bounded to 5 s and never retried in a loop; a failure is recorded on the alert as a fixed status. | remote/webhook_tests.rs: URL validation, sealing round trip, delivery to a local test receiver, failure and redirect statuses without following. |
TM-TENANT-1 — multi-tenant data access
Assets: membership, tenant-scoped records, billing/workspace IDs, cache keys, jobs, files, logs and exports.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
TENANT-01 trusted client header | Never accept tenant/role solely from an arbitrary header; bind to authenticated membership or a trusted gateway. | Strict tenant guard rejects absent context; gateway policy is application-owned. |
TENANT-02 object ID crosses tenant | Query with tenant/owner predicate and authorize the returned object server-side. | UserContext carries a validated optional tenant and RbacGuard::authorize_tenant[_owner_or_role] requires an exact match that even admin cannot bypass. The materialized LMS starter test exercises its enrollment-owner boundary (ACADEMY-02); general route coverage remains open. |
TENANT-03 bulk/list/export leakage | Apply tenant predicate before pagination/count/export and validate every bulk member. | Must be proven per generated/application route. |
TENANT-04 cache/queue/storage collision | Include canonical tenant identity in keys, jobs and storage roots; validate again in workers. | Core’s TenantStorage, TenantCache, TenantRealtime and TenantPresence can only be constructed from a validated TenantContext, apply immutable tenant namespaces and have exact same-key/channel/presence-room non-interference tests. Cache keys, realtime channels and presence rooms share one tenants:<tenant>:<logical> builder whose tenant segment encodes % as %25 and : as %3A, so a logical name containing : cannot alias another tenant (identifiers without those characters keep their previous names); ("a", "b:c") versus ("a:b", "c") is tested for cache, broadcast and presence. Tenant identifiers made only of dots are rejected, and TenantStorage also refuses any tenant that is not exactly one normal path segment, with an exact tenant-. negative. On local storage it also refuses a tenant whose tenants/<tenant> directory the filesystem resolves under another name, so case-variant identifiers (Acme/acme on APFS or NTFS) and Windows trailing-dot stripping (acme.) cannot share a directory; the first tenant to create it keeps it. The cache wrapper exposes no cross-tenant flush; the realtime wrappers validate names and bound payloads. Generated ORM Redis model hashes bind the application namespace plus opaque tenant and table digests, require a typed tenant context for tenant models and reject another tenant’s handle or decoded hash; an opt-in live Redis regression proves tenant/namespace separation, while the orm:events:* channels stay un-namespaced. Application room authorization, distributed realtime/liveness, search, metrics, exports, distributed cache/failover and remote bucket policy remain open. |
TENANT-05 confused-deputy background work | Persist actor/tenant/authorization intent and revalidate sensitive execution. | Shared durable job authorization contract remains open. |
Trust boundaries are identity ↔ membership, route ID ↔ object and application ↔ database/cache/queue/storage.
TM-ORM-1 — portable document recovery
Assets: document contents, portable identifiers, application/collection scope, snapshot encryption keys and the destination collection.
Trust boundaries: application writer ↔ repository inventory; source store ↔ application-owned snapshot storage; operator key custody ↔ snapshot opener; and snapshot contents ↔ destination repository.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
ORM-01 copied, tampered or cross-scope snapshot discloses or substitutes documents | Encrypt and authenticate the complete versioned payload with a fresh nonce, explicit rotation key ID, length-delimited application/collection binding and fixed decode limits; authenticate before JSON decoding. | The exact negative changes ciphertext, key material and application binding and proves all three fail closed. Key bytes are consumed through a zeroizing temporary, opaque snapshot/key Debug output is redacted and plaintext labels are absent from the envelope. Key generation/custody/rotation, external snapshot permissions and deletion remain operator responsibilities. |
ORM-02 partial retry overwrites conflicting destination data or silently accepts extras | Accept only an empty destination or an exact matching subset, insert without replacement, treat only an exact raced duplicate as replay and verify the final complete inventory. Never delete an extra row or claim transactionality across stores. | The exact negative proves a differing row and an extra row fail before mutation. Deterministic tests cover partial resume and idempotent replay; the live matrix exports MongoDB→SurrealDB→MongoDB and checks ordered IDs. Export performs two equal observations, but formal consistency still requires the application to quiesce writers; a failed restore can retain prior successful inserts for the documented retry path. |
Release-negative minimum: ciphertext/key/scope substitution and conflicting or extra destination state.
TM-SEC-1 — declared HTTP payload contracts
Assets: route semantics, validated request data, schema configuration and security telemetry.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
SEC-16 malformed, ambiguous or schema-confused JSON body | Require a JSON media type on unsafe schema-bound requests, classified as the route’s JSON extractor does (any ASCII case, parameters, json subtype or +json suffix) so an accepted spelling cannot skip the checks; reject malformed, duplicate-key, oversized or deeply nested input before applying a precompiled closed schema. Never fetch attacker-selected schema references. | The bounded route policy compiles JSON Schema 2020-12 or one OpenAPI 3.1 component with local references, no network/filesystem resolver and linear-time regexes. The exact negative covers shape/additional-property confusion and external references; middleware tests cover 415/400/422 and exact body preservation. Auth, ownership, domain rules and non-JSON parameters remain separate. |
Trust boundaries are application schema configuration ↔ compiled validator and untrusted HTTP body ↔ route handler.
TM-SEC-2 — anomaly assessment and proof-of-work admission
Assets: service availability, canonical client subjects, challenge key, challenge capacity and replay state.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
SEC-07 forged, replayed, cross-subject or resource-exhausting challenge | Authenticate the complete challenge, bind it to one canonical subject, cap difficulty/TTL/cardinality, reject invalid work, expire it and atomically consume one successful proof. Classification must remain explainable and must not become authorization by itself. | The exact negative tampers with the token, changes the subject, submits invalid work, verifies concurrently and replays/expires the challenge; only one local verifier succeeds. Aggregate collection, proxy/device identity, accessible alternatives, distributed replay state, adaptive evaluation and enforcement remain application/deployment work. |
Trust boundaries are host-supplied aggregates ↔ deterministic classifier, application subject ↔ challenge and process-local replay state ↔ distributed deployment.
TM-SEC-3 — authenticated local security-event journal
Assets: normalized security-event evidence, HMAC keys, key identifiers, record order and durable local file state.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
SEC-33 forged, substituted or rotation-confused local event | Authenticate a domain-separated canonical frame containing sequence, key identifier, predecessor tag, payload length and exact normalized event bytes; reject absent/wrong keys, forgery, reordering and removed interior records before returning an event as HMAC-verified. | The exact negative proves payload tampering, same-identifier wrong-key substitution and absent historical-key rotation fail closed. Additional deterministic tests cover restart across active-key rotation, predecessor/sequence attacks, quotas, symlink targets, external length changes and redacted/zeroizing key storage. This is one local writer and a maximum of eight keys/4,096 records/16 MiB; trusted key/path custody, whole-tail rollback checkpoints, multi-writer coordination, compaction, retention, remote delivery and acknowledgement remain operator/application work. |
Trust boundaries are local event producer ↔ authenticated journal, key manager ↔ rotation ring and filesystem state ↔ restart verifier.
TM-PAY-1 — webhooks and billing boundaries
Assets: provider secrets, event IDs, subscription state, amount/currency, invoice identity and replay state.
v13 keeps the provider-neutral base, Stripe and the experimental InfinitePay adapter. Payout and NFS-e fiscal code left the framework, so their cases below are retired and claim no current evidence; applications that add custom gateways, payouts or fiscal integrations own those threat models.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
PAY-01 forged webhook | Verify the provider’s exact signed bytes and algorithm cryptographically before parsing side effects. | Axum and Actix call the same verifier; Stripe signature negatives plus Actix body/event preservation prove the bounded adapters. The experimental InfinitePay adapter rejects live deliveries with UnsupportedOperation because no reviewed authentication contract exists; its mock_* verification is offline-only. Custom adapters must declare their verification mode, and the trait default fails closed. |
PAY-02 replay/stale event | Enforce timestamp window and unique event ID in durable shared state before effects. | Freshness and bounded local replay are enforced before dispatch. The opt-in SQL ledger persists provider-scoped payload/event claims across SQLite/PostgreSQL/MySQL/MariaDB processes, fails closed on capacity/storage/profile errors and has restart/contention/four-protocol live evidence. Middleware claims before dispatch and is not exactly-once delivery; stable event IDs can instead share the caller’s relational domain transaction, and a live MySQL/MariaDB/PostgreSQL case proves a caller transaction whose snapshot predates a concurrently committed claim is rejected. A custom adapter for a provider that signs only the body checks no delivery timestamp, so an exact captured body verifies again once its replay claim expires (24 hours by default, at most 30 days) or, with the process-local store, after a restart; hosts must order and deduplicate their state changes durably. |
PAY-03 amount/owner substitution | Derive product/currency/owner from authenticated server state, never arbitrary client values. | Materialized SQLx/Turso billing scaffolds bind checkout to authenticated identity and deny cross-owner subscription reuse before customer binding; route/plan/provider review remains open. |
PAY-04 duplicate/partial transition | Use a transactional idempotent state machine and reconcile with the provider. | check_and_record_event_key_with_transaction can bind one stable provider event ID to one mutation in the same supported relational database. Cross-system atomicity, provider reconciliation, automatic state-machine policy, and middleware crash recovery remain open. |
PAY-05 payout destination takeover | Retired in v13. | The Wise payout adapter and payout contracts were removed; no current evidence is claimed. Applications that add payouts own step-up authentication, destination allowlists/change delays and independent audit. |
PAY-06 false fiscal authorization | Retired in v13. | NFS-e preparation was removed from the framework (it may return as a separate product); no current evidence is claimed. Rullst receipts never represent tax authorization. |
PAY-07 forged or confused SEFIN issuance response | Retired in v13. | Removed with the NFS-e module; no current evidence is claimed. |
PAY-08 replayed or substituted direct charge | Require integer minor units, currency, authoritative provider customer/payment-method identity and a durable order idempotency key; bind the accepted response before side effects. | ChargeRequest validates and redacts the bounded inputs. Stripe forwards Idempotency-Key, confirms off-session and rejects responses with mismatched amount/currency or non-accepted status. Offline receipts carry the distinct non-success Mock status and other adapters fail unsupported. The host still owns mandate/SCA setup, identity authorization, durable key uniqueness, webhook reconciliation and entitlement state. |
PAY-09 substituted or replayed paid invoice delivery | Bind the recipient, exact minor-unit total and currency to final non-mock payment evidence; expose a stable delivery identity and require durable claiming before retryable effects. | PaidInvoice rejects Processing/Mock and any e-mail, amount or currency mismatch. The opt-in Mail bridge renders bounded HTML/PDF, runs mandatory pre-flight and retains a stable delivery key. The host still owns authenticated order construction, webhook reconciliation, atomic outbox claiming, retry policy and provider acceptance; delivery is not exactly once. |
PAY-10 duplicated or response-confused metered usage | Bind every accepted response to provider-specific customer/item, metric identity, quantity, timestamp/action and retry evidence; never infer missing provider fields from a uniform subscription ID. | Stripe Meter Events forward a bounded identifier and recheck customer/event/value/timestamp/identifier, cap response bytes, redact identities and keep mocks visibly non-live. A custom adapter without a provider event key reports ApplicationOutboxRequired so the application claims the key durably. Provider-account acceptance, configured aggregation, durable storage/retry and invoice reconciliation remain application/operational work. |
Trust boundaries are provider ↔ webhook, user ↔ checkout, application ↔ provider and application ↔ durable billing state.
TM-MAIL-1 — outbound content, suppressions and delivery telemetry
Assets: recipients, message content and attachments, provider event identities, suppression state, tenant identity and operational observations.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
MAIL-01 executable, active or type-confused attachment leaves the process | Validate bounded metadata and recognizable signatures, reject active content and fail closed when authoritative inspection is unavailable. | The opt-in static AttachmentInspectionGuard completes inspection before the wrapped transport. Its strict local heuristic rejects executable magic, spoofed known types, active PDF/SVG, secrets and unsafe text links. The declared MIME type alone never selects the checks: it is compared case-insensitively and combined with the filename extension and content signature, so an active PDF, SVG, HTML or script file labelled text/plain or application/xml is rejected (SVG and XHTML are recognized by root element after any prologue or DOCTYPE and namespace prefix, and by namespace URI, as browsers parse them), and the strict policy rejects a declared type it does not inspect (such as text/html on invoice.txt) unless it is application/octet-stream; executable/script-host extensions are rejected under both policies. PDF #xx name escapes and compressed streams are not decoded. It is not antivirus, sandboxing, recursive archive inspection or CDR; production risk policy may require an independently operated scanner adapter. |
MAIL-02 forged, replayed or lost suppression event permits unwanted delivery | Authenticate the provider event before mutation, bind provider/event/payload exactly, persist suppression durably and check it before transport. | The opt-in SQLite store provides exact replay conflict detection, monotonic manual/bounce/complaint state, immutable quotas and shared-local restart/two-instance evidence; SuppressionGuard fails closed on suppressed recipients or unavailable state. One recipient parser reduces Name <address> forms to the bare address for the pipeline, disposable check, verified feedback and store lookup, so a display name cannot miss a suppression entry; unparsable recipients are rejected. Rullst does not authenticate provider webhooks in this API, and only already-verified events may be recorded. Replay-ID retention must cover the provider window. The optional v13 PostgreSQL store shares state across hosts behind a monotonic namespace clock that tolerates at most 300 s of backwards host skew and fails closed beyond it. Multi-host replication, webhook adapters and provider-account acceptance remain open. |
MAIL-03 message or recipient leaks through delivery telemetry | Emit only bounded low-cardinality outcomes and never recipient, subject, body, filename or provider response content. | ObservedMailDriver records provider label, outcome, elapsed time, attachment count and two booleans through a non-failing static sink. The bounded default sink is process-local; externally operated metrics/tracing export, retention and alerting remain deployment work. |
MAIL-04 oversized or adversarial message content exhausts the sending executor | Bound scanned text before inspection and keep every content scan linear in its input. | The mandatory pipeline rejects a subject over 2 KiB or an HTML/text body over 2 MiB before scanning; link extraction deduplicates through a hash set and the redactors and URL search are single forward passes. A regression test runs marker-, link- and PEM-dense bodies at the limit under a deadline. Scans still run on the calling task, so hosts should also cap request bodies. |
MAIL-05 a production misconfiguration silently selects the offline mock and loses mail | Select offline delivery only from explicit mock configuration and reject partial credentials. | SMTP selects its offline fixture only for an empty/mock_* host or a mock_* username/password; a real host without credentials is a real relay and a lone username or password is a ConfigError. REST providers keep the documented empty/mock_* credential convention, so deployments should assert delivery_mode() at startup. Without MAIL_DRIVER (process environment, then .env) or [mail] driver, the facade falls back to the metadata-only log driver only in development/test, resolved with the Server environment precedence; staging and production fail with ConfigError. A MAIL_DRIVER or [mail] driver naming a transport removed in v13 (sendgrid, postmark, mailjet, mailtrap, their sandbox values or azure-acs) fails with a ConfigError naming it instead of falling back, and a queued job then fails visibly and stays retryable. |
Trust boundaries are authenticated application state ↔ mail message, application ↔ inspection/suppression adapters, verified provider event ↔ local suppression state and transport result ↔ observation sink.
TM-MESSAGING-1 — durable messages, wire frames and correlation
Assets: message payloads and headers, idempotency state, ACK leases, consumer progress, storage keys, broker routing metadata and trace correlation.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
MESSAGING-01 protected message content is recovered from a copied SQLite file | Offer an explicit authenticated-encryption profile, encrypt header values and payload before persistence, and fail startup on profile/key mismatch. | connect_encrypted uses AES-256-GCM with random nonces and a bounded keyring. A raw-database/restart regression proves the selected header and payload are absent while delivery round-trips. Topic, event/content type, IDs, timestamps, idempotency key, fingerprint and delivery state remain visible metadata; host key custody, permissions, backup and erasure remain required. |
MESSAGING-02 ciphertext or metadata is copied, reordered or altered | Bind namespace, topic, sequence, message ID, event/content type, timestamp and rotation key ID into AAD; reject authentication failure before claiming the message. | The exact negative swaps two valid ciphertext rows and receives StorageAuthenticationFailed; probe tamper and wrong-key tests cover startup. AES-GCM does not prevent deletion, rollback of the complete database or availability loss, so protected backup/rollback detection remains deployment work. |
MESSAGING-03 unsafe rotation silently makes retained records unreadable | Track every retained record’s non-secret key ID, require every referenced prior key at startup and open one retained record per referenced key ID to prove its bytes, use only the primary key for new writes and permit removal only after old records are purged. | The rotation regression opens old+new, writes with the new primary, rejects new-only or a prior key with the right ID but wrong bytes while an old record remains, then permits new-only after terminal purge. External secret-manager rotation, escrow, recovery rehearsal and multi-host rollout remain operator work. |
MESSAGING-04 malformed, oversized, cross-namespace or future-version wire frame enters broker state | Bound the frame before payload allocation, validate every field and canonical ordering, require exact namespace and reject unknown versions/trailing bytes. | A fixed byte digest freezes v1; exact negatives cover every truncation, wrong magic, unknown version, trailing data and namespace mismatch. The codec is only an envelope primitive, not a remote transport or provider protocol. |
MESSAGING-05 attacker-controlled trace metadata leaks baggage or creates ambiguous correlation | Propagate only strictly validated W3C version-00 traceparent and a conservative tracestate subset; exclude baggage and redact diagnostics. | Grammar, duplicate-key, zero-ID, uppercase and malformed-member negatives are exact; an in-memory delivery proves only the two allowlisted headers. Sampling, tenant authorization, exporter security and retention remain host work. |
MESSAGING-06 process stops after broker publication but before acknowledging the relational outbox | Commit domain state plus outbox row together, claim with a lease, publish the stream-scoped outbox event key as broker idempotency, then ACK the exact claim. Never call the two systems one atomic transaction. | The opt-in static OrmOutboxRelay maps one configured stream/topic, validates the claimed JSON and keeps payload/event/claim keys out of Debug. An exact crash-window regression publishes, lets the claim expire, republishes through a new claim, observes the broker’s duplicate receipt and only then ACKs; one message exists. Worker supervision, retention, tenant authorization and destination idempotency remain application work. |
Trust boundaries are publisher/consumer ↔ broker contract, process ↔ SQLite file/key manager, local envelope ↔ future remote adapter and untrusted correlation headers ↔ tracing infrastructure.
TM-AI-1 — prompts, RAG and tool execution
Assets: system prompts, tenant content, provider keys, retrieved data, tool credentials, destructive operations and audit evidence.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
AI-01 direct/indirect prompt injection | Treat prompts/retrieval as untrusted and never equate heuristic pass with safety. | The versioned rullst-ai-guardrails-v1 offline corpus runs injection/jailbreak cases across all built-in transports; adaptive and live-model evals remain open. |
AI-02 PII/secret exfiltration | Minimize/redact before dispatch and enforce tenant-aware retrieval. | The versioned offline corpus fixes exact implemented email/card/CPF/CNPJ redactions across built-in transports; CPF/CNPJ masking requires valid check digits and does not recognize alphanumeric CNPJs. RagPipeline guards/masks every selected passage, requires trusted tenant context and rejects mismatched tags; the application retriever must still apply authoritative datastore tenant/ownership predicates, and raw provider calls remain an explicit bypass boundary. |
AI-03 unauthorized tool/arguments | Allowlist typed tools, validate schema and authorize as the initiating subject after selection. | Local registry dispatch requires exact allowlist, principal authorization, closed bounded JSON, call budget and audit; destructive/financial approvals are one-use and payload-bound. Provider-native selection loops, domain authorization, approver authentication and durable audit remain open. |
AI-04 destructive autonomous action | Require human approval and idempotency for finance, deploy, deletion or privilege change. | Not yet a general framework guarantee. |
AI-05 SSRF/egress exfiltration | Validate destinations, block private/link-local/metadata networks and cap redirects/content/time. | EgressPolicy::strict() denies all hosts until an exact allowlist is configured. The opt-in EgressFetcher resolves under deadline, validates every answer, pins them into a proxy-free client, verifies the peer, revalidates manual redirects and bounds declared/streamed bytes; deterministic negatives stop private/mixed DNS before transport. It does not automatically wrap provider/application clients, and tenant-aware destination authorization, response validation plus a successful live-origin redirect/stream contract remain open. |
AI-06 unbounded cost/availability | Enforce body/token/time/concurrency budgets, cancellation and circuit breaking. | Built-in transports have configurable request deadlines and local tools have call/payload budgets; provider-neutral cancellation, concurrency limits and circuit breaking remain open. |
AI-07 hallucinated structured result | Require schema and authoritative server-side validation; prose is never authorization. | Structured-output foundations exist. |
AI-08 cross-tenant, reordered or incompletely erased chat memory | Select storage only from trusted tenant/conversation context, commit complete exchanges atomically, reject stale writers and erase the exact key. | The reusable stores bind tenant plus validated conversation ID and enforce bounded consecutive user/assistant pairs. The SQL adapter uses transactional revision CAS; its exact SQLite negative proves isolation, a single winner, deletion with foreign keys disabled and no orphaned messages, while live matrices cover PostgreSQL/MySQL/MariaDB protocols. MySQL/MariaDB key columns use ascii_bin and every tenant-scoped statement compares the key byte-exactly; the MySQL 8.0 and MariaDB matrices prove that tenant and conversation IDs differing only by case stay separate, that a legacy case-insensitive table fails closed and that the documented migration restores separation. Authentication/ownership inside a tenant, encryption, retention deadlines and backup erasure remain host policy. |
Trust boundaries are content ↔ model context, application ↔ provider, model output ↔ tool dispatcher and retriever ↔ network/data sources.
TM-IOT-1 — OTA manifest and device lifecycle
Assets: provisioned public key, firmware image/hash, target, monotonic version, boot state and telemetry.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
IOT-01 forged/tampered manifest | Verify Ed25519 over canonical bounded bytes with the provisioned key. | Signature/tamper tests. |
IOT-02 rollback | Require version above a persisted monotonic counter before boot. | In-process anti-rollback exists; persistent integration remains open. |
IOT-03 wrong target/image | Verify target, declared length and cryptographic hash before flashing. | Manifest checks exist; downloader/flasher is roadmap. |
IOT-04 power loss/partial flash | Use a recoverable A/B boot flow and commit counter only after verified boot. | The target bank is always opposite the running bank that platform code reports; a commit does not move the running bank, and a regression proves a second update before reboot never targets it. Hardware/bootloader integration remains open. |
IOT-05 signing-key compromise | Use offline protected signing plus rotation/revocation. | Operational/HSM program remains open; simulators are not HSMs. |
IOT-06 telemetry spoof/replay | Authenticate channel/device and bind identity plus sequence/time. | Transport identity/MQTT remains open. |
Trust boundaries are signer ↔ distribution, distribution ↔ device, verified manifest ↔ flasher/bootloader and device ↔ backend.
TM-ACADEMY-1 — education, assessment and games
Assets: school and tenant membership, learner identity, protected content, enrollment/entitlement, lesson progress, submissions and grades, score events, leaderboards, rewards, automations, payment state, minors’ data and administrative evidence.
Actors and trust boundaries: learner, guardian, instructor, evaluator, moderator, support operator, school owner and platform administrator; browser or game client ↔ application; application ↔ school membership/database/cache/ queue/storage; application ↔ billing/media/notification provider; automation or AI output ↔ authorized domain command. The client is never authoritative for identity, entitlement, grade, score, reward or payment state.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
ACADEMY-01 forged learner, role or school context | Derive the subject and memberships from an authenticated server-side session or reviewed gateway; never trust form/header identity. | The LMS example (examples/lms, the former LMS starter) derives its numeric learner ID from the encrypted session cookie, requires that user to exist and binds it with a fixed student role into a server-created UserContext; it has no schools, memberships or tenant selector. Invite/provisioning, roles and the separate production identity lifecycle remain application work. |
ACADEMY-02 cross-user lesson or progress access | Resolve the lesson and active enrollment, authorize the enrollment owner before returning media or writing progress, and deny before side effects. | The LMS example’s (examples/lms, the former LMS starter) learning service authorizes the enrollment owner from UserContext before any database access, and its routes sit behind the authenticated middleware. The example’s own test security_tests::another_learners_lessons_are_refused (rullst-lms-example) executes that owner boundary. |
ACADEMY-03 unenrolled or expired content access | Check a current server-side entitlement on every protected lesson, download and media request; signed URLs must be short-lived and subject-bound. | Retired in v13 (TM-13.0). The full Academy blueprint that implemented this was removed; the LMS example has no enrollment release rules, expiry or signed media URLs. An application that adds them must implement and test this disposition. |
ACADEMY-04 cross-school data leak | Scope reads, counts, exports, cache keys, jobs, files, search and telemetry by authenticated school membership and test non-interference. | Retired in v13 (TM-13.0). The full Academy blueprint that implemented this was removed; the LMS example has no schools, memberships or cohorts. An application that adds them must implement and test this disposition. |
ACADEMY-05 assessment or grade tampering | Version authoritative questions/rubrics, enforce attempt/time limits server-side, grade trusted inputs and audit every override. | Retired in v13 (TM-13.0). The full Academy blueprint that implemented this was removed; the LMS example has no quizzes, assignments or grades. An application that adds them must implement and test this disposition. |
ACADEMY-06 replayed, impossible or reordered score | Authenticate a versioned ScoreEvent, recompute or validate results, enforce unique attempt/deduplication keys and deterministic leaderboard ordering. | Retired in v13 (TM-13.0). The full Academy blueprint that implemented this was removed; the LMS example has no score events or leaderboards. An application that adds them must implement and test this disposition. |
ACADEMY-07 duplicate or confused-deputy automation | Commit a transactional outbox with domain state, deliver at least once, make handlers idempotent and reauthorize sensitive actions as the recorded actor/tenant. | Retired in v13 (TM-13.0). The full Academy blueprint that implemented this was removed; the LMS example has no outbox-driven automation. An application that adds them must implement and test this disposition. |
ACADEMY-08 admin, support or AI privilege escalation | Apply least privilege and separation of duties, require step-up/approval for sensitive mutations and treat AI output only as untrusted input to guarded tools. | Retired in v13 (TM-13.0). The full Academy blueprint that implemented this was removed; the LMS example has no publication review, role grants or an AI admin. An application that adds them must implement and test this disposition. |
ACADEMY-09 malicious upload or active content | Enforce real type/size/name limits, isolate parsing, quarantine/scanning and distinct SVG/HTML/archive/document/media policies. | Core now exposes a bounded in-memory admission contract with a hard size ceiling and application allowlist, canonical tenant/name checks, exact declared MIME/extension versus recognized signature, active-text rejection (text whose first significant character after whitespace, byte-order marks or zero-width characters opens markup, including XML prologs, comments and doctypes, or that contains javascript:), tenant-prefixed randomized quarantine keys, SHA-256 binding, a static-dispatch scanner contract and fail-closed release. The deterministic scanner is explicitly mock-only. The exact negatives reject active SVG/HTML text, including BOM-, prolog- and comment-prefixed markup, traversal, invalid tenant and MIME/extension spoofing. Multipart streaming, remote S3/R2 persistence, sandboxed parsers/transcoding, archives and a production malware adapter remain open. |
ACADEMY-10 minors’ privacy or unsafe retention | Minimize collection, separate guardian consent when required, implement export/deletion/retention/anonymization and keep PII out of logs/telemetry. | Retired in v13 (TM-13.0). The full Academy blueprint that implemented this was removed; the LMS example has no age bands, guardian consent or retention workers. An application that adds them must implement and test this disposition. |
ACADEMY-11 payment-to-entitlement substitution | Derive product, school, learner and amount from server state; apply verified idempotent provider events before granting or revoking entitlement. | Capital webhook foundations exist, but the LMS example has no billing-to-entitlement integration and makes no paid-course claim. |
ACADEMY-12 availability, farming or multi-account abuse | Apply identity and origin budgets, replay controls, anomaly review and accessible rules without using a manipulable cache as source of truth. | The optional redis-rate-limit adapter provides atomic shared counters, bounded windows and hashed client keys; empty/mock_* configuration is visibly process-local and fails require_distributed(). The exact negative gate proves that fail-closed boundary, while a separate CI/release contract proves two independent clients consume one budget against digest-pinned Redis. Composition of the shared limiter into generated HTTP routes, cluster/failover and domain-specific farming/multi-account tests remain open. |
Academy negative minimum before a comparative security claim: anonymous,
cross-user, cross-course and cross-school content/progress requests; expired
entitlement; assessment replay/time manipulation; duplicate/impossible score;
automation redelivery; unauthorized grade/admin mutation; hostile upload;
payment replay; and multiple-account abuse. The exact release-negative gate now
exercises the LMS example’s owner/cross-user boundary (ACADEMY-02), hostile
upload admission (ACADEMY-09) and the explicit distributed-rate-limit startup
boundary (ACADEMY-12). Cross-school, assessment and score cases were retired
with the Academy scaffold; tenant isolation, Redis failover and domain-specific
multi-account behavior remain outside that bounded evidence.
TM-LABS-1 — trusted grading and the host-owned runner boundary
Assets: learner source, hidden grader cases and expected answers, job status and grades, the encrypted job database and content key, the receipt signing seed, and the hosts that execute submissions.
Actors and trust boundaries: learner and instructor ↔ application;
application ↔ rullst-labs shared-local job store; application-owned runner
controller ↔ job store and signing seed; controller ↔ untrusted isolated
worker. rullst-labs executes nothing. The rullst-labs-runner candidate was
removed from the 13.0 workspace, so every execution, isolation and teardown
control below the controller belongs to the application’s own runner, described
by the controller contract.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
LABS-01 learner code runs in the web process or beside application secrets | Never execute in the HTTP process. The worker receives only bounded WorkerInput data, without identity, expected answers, keys, database, network or control sockets. | check-labs-boundary.py rejects an executor binary, an execution-engine dependency in any workspace member and a reintroduced runner crate. one_lease_and_exact_trusted_grading_without_worker_answers_or_authoritative_pass_flag checks the worker input. Worker isolation is host-owned and not verified by Rullst. |
LABS-02 forged or self-awarded grade | Accept results only under the pinned controller key; grade worker values against stored answers on the trusted side; never accept a pass flag. | pinned_receipt_key_exact_binding_and_teardown_control_experimental_grades and signed_mixed_outcomes_award_only_exact_answers_and_preserve_minimized_feedback. Signing-seed custody is host-owned. |
LABS-03 stale, replayed, cancelled or cross-job result | Bind receipts to the current lease nonce, revision and request/profile/source digests; refuse expired, cancelled or withdrawn work; treat an exact replay as an idempotent read. | cancellation_fences_late_results_and_retention_waits_for_confirmed_cleanup, withdrawal_and_expiry_prevent_a_validly_signed_late_grade and failed_controller_fences_immediately_and_old_attempt_cannot_stop_a_retry. |
LABS-04 lost worker or uncertain teardown treated as success | Fence first; require a signed WorkerLost cleanup attestation with confirmed teardown; allow at most one deliberate retry with a fresh nonce. | lost_worker_requires_cleanup_and_a_fresh_nonce_before_one_bounded_retry and the byo_runner_controller example tests. Whether teardown actually happened is host-owned. |
LABS-05 hidden cases or source disclosed | Encrypt source and graders at rest, redact Debug output, return keyed exercise digests and erase source at terminal states. | source_and_grader_are_encrypted_and_record_index_tampering_is_detected, untrusted_source_and_expected_answers_are_bounded_and_not_debug_output and returned_views_never_carry_the_unkeyed_exercise_digest. Runner-side workspaces, logs and backups are host-owned. |
LABS-06 resource exhaustion through submissions | Bound source, cases and limits; enforce store-wide and per-learner job budgets; rate-limit Submit in host authorization; enforce CPU, memory, process, disk and output limits in the runner. | one_learner_cannot_fill_the_store_wide_job_budget, submissions_that_could_never_be_claimed_are_refused and malformed_identity_and_expanded_execution_policy_are_rejected. Runtime limits are host-owned. |
Host-owned runner boundary: the removed candidate’s first-profile threat model and hosted evidence describe software Rullst no longer ships. An operator deploying a runner must extend this model with its own platform threats (sandbox and kernel escape, network and cloud-metadata reachability, cross-tenant caches, toolchain provenance, resource exhaustion and incomplete teardown), test the negatives on the deployed host and obtain independent review before any production hostile-code claim.
TM-DEPLOY-1 — CLI, artifacts and release/deployment
Assets: source, generated projects, registry token, release tag, .crate
archives, evidence, deployment credentials and production target.
| Abuse case | Required disposition | Repository evidence or remaining work |
|---|---|---|
DEPLOY-01 tag/version/DAG mismatch | Match every package/internal requirement to the tag and publish topologically. | Machine-readable order and metadata preflight. |
DEPLOY-02 artifact/source substitution | Package from tag, inspect/checksum/attest, reproduce in publish job and compare bytes. | Workflow gates exist; real RC run remains open. |
DEPLOY-03 secret/local artifact leakage | Deny .env*, key/certificate patterns, unsafe paths and unexpected archives. | .crate audit gate. |
DEPLOY-04 partial irreversible publish | Wait for index/checksum after each crate and resume only if the existing checksum matches. Never reuse a version. | Workflow exists; recovery runbook remains open. |
DEPLOY-05 CLI command/path injection | Validate identifiers, paths and image names; avoid shell interpolation and propagate failures. | Generator injection/path tests. |
DEPLOY-06 generated insecure default | Compile materialized matrices and assert production fails closed while local shortcuts are loopback debug-only. | Structural/representative blueprint matrices plus materialized auth, mail, chat and SQLx/Turso billing contracts exist; final RC/multi-OS evidence remains open. |
DEPLOY-07 compromised dependency/action | Pin actions, lock dependencies, audit advisories/licenses/sources and govern expiring exceptions. | Policy exists; tag-bound result remains open. |
Trust boundaries are contributor/CI ↔ repository, tag ↔ artifact, artifact ↔ registry and CLI ↔ filesystem/process/cloud.
Review and change control
- Changes to authentication, authorization, tenant resolution, webhooks, AI tools, OTA or release flow must reference at least one abuse-case ID in their test or review description.
- New boundaries receive new IDs; IDs are never silently reused.
- Closing residual risk requires code/configuration, a negative test and commit-bound evidence. Documentation alone cannot claim a control is deployed.
- For every v13 release, maintainers must review TM-13.0 against the exact candidate, applications must add topology/provider threats and an independent reviewer must cover the highest-impact paths.
Security Assurance Case
This page argues why Rullst’s security requirements are met, and where they
are not. It ties together the threat models, the trust boundaries, the design
principles and the countermeasures for common weaknesses, and points to the
code and tests behind each argument. Source paths are relative to the
repository root; tests are named
as file::test_function.
It covers the framework crates, the cargo rullst CLI and the release
pipeline. It is not a certification, a penetration test or a guarantee for any
application built with Rullst. Applications own their data model, identity
policy, deployment and providers, and must extend the threat model for them.
Top-level claim
Used as documented, Rullst’s framework-owned controls counter the abuse cases named in its threat models, fail closed when they are missing or misconfigured, and ship in releases that users can verify. Every narrower claim below cites its mechanism and at least one negative test. The limits section lists what is not claimed.
Threat model
The threat models (model version TM-13.0; v12 keeps
TM-12.10) use a lightweight STRIDE review. Each model separates untrusted
network input, authenticated application identity, process-local state, shared
durable state and third-party or hardware trust, and names abuse cases with
stable IDs such as AUTH-06 or STUDIO-01. There are 21 models:
| Area | Models |
|---|---|
| Runtime and network identity | TM-CORE-1 (readiness and drain), TM-CORE-2 (client identity behind proxies) |
| Identity | TM-AUTH-1 (sessions, passwords, OAuth/OIDC, passkeys), TM-CONNECT-1 (OAuth token generations) |
| Administration and tooling | TM-NEXUS-1 (admin CMS), TM-STUDIO-1 (local control room), TM-STUDIO-2 (production monitoring endpoint), TM-STUDIO-3 (Studio remote projects) |
| Data | TM-TENANT-1 (multi-tenant access), TM-ORM-1 (document recovery) |
| Request defenses | TM-SEC-1 (payload contracts), TM-SEC-2 (anomaly and proof-of-work admission), TM-SEC-3 (security-event journal) |
| Integrations | TM-PAY-1 (webhooks and billing), TM-MAIL-1 (outbound mail), TM-MESSAGING-1 (messages and wire frames), TM-AI-1 (prompts, RAG, tools) |
| Devices and education | TM-IOT-1 (OTA and devices), TM-ACADEMY-1, TM-LABS-1 |
| Supply chain | TM-DEPLOY-1 (CLI, artifacts, release and deployment) |
The machine-readable minimum in
.github/threat-model-release-minimum.json
binds 52 abuse-case IDs to 70 evidence rows and 69 exact tests across thirteen
crates and the LMS example. check-threat-model-release-minimum.sh rejects
missing markers, missing tests and filters that match zero tests, then runs
each test with --exact. It runs in four ci.yml shards after every merge to
main and nightly (pull requests to main skip it), and release admission
requires those four shards to have passed on the exact tagged commit.
Changes to authentication, authorization, tenant resolution, webhooks, AI
tools, OTA or the release flow must reference an abuse-case ID (see
review and change control).
Related evidence: the historical v12 security claims ledger lists each narrow v12 claim with its tests and known limits (for v13 this page and the threat models take its place), and the external audit kit gives third-party reviewers the scope, a sample application and tooling.
Trust boundaries
| Boundary | Untrusted side | Enforced by | Model |
|---|---|---|---|
| Browser or client ↔ application | Every request, header, cookie, WebSocket upgrade and body | The canonical production order (rullst-core/src/production.rs): trusted-proxy policy, body limit, secure headers, CORS allowlist, WAF/RASP, CSRF, then application-owned session, authentication, tenant and authorization slots | TM-AUTH-1, TM-SEC-1 |
| Reverse proxy ↔ application | Forwarded and X-Forwarded-For | TrustedProxyLayer (rullst-core/src/security/trusted_proxy/): the direct socket peer is the identity until exact proxy hops are configured | TM-CORE-2 |
| Identity ↔ tenant and object | Route IDs and tenant claims | RbacGuard::authorize_owner_or_role and UserContext (rullst-security/src/rbac/guard.rs), TenantContext (rullst-core/src/security/tenant_guard.rs), ORM tenant_column scoping | TM-TENANT-1 |
| Application ↔ database | Values and identifiers that reach SQL | Bind parameters; identifier validation and operator allowlists in the query builder | TM-ORM-1, TM-TENANT-1 |
| Application ↔ external providers | Webhooks, OAuth/OIDC responses, AI output, provider URLs | Signature checks with constant-time comparison, replay stores, HTTPS-only provider URLs, deny-by-default egress | TM-PAY-1, TM-MAIL-1, TM-CONNECT-1, TM-AI-1 |
| Content ↔ model; model output ↔ tools | Retrieved documents and model replies | Prompt-injection filter, PII masking and guarded tool dispatch (guarded local AI tools) | TM-AI-1 |
| Developer tools ↔ network | Remote access to Studio and debug endpoints | Debug-only, Development-only, loopback-only mounting; same-origin checks for state changes | TM-STUDIO-1, TM-NEXUS-1 |
| Monitoring client ↔ application | Requests to the opt-in /_rullst/monitor/v1 endpoint | Off by default; bearer tokens compared in constant time or the application’s authorizer; TLS evidence in staging and production; per-client rate limit and lockout; aggregates keyed by route pattern only (rullst-core/src/monitor/) | TM-STUDIO-2 |
| Firmware signer ↔ device | OTA manifests | Ed25519 manifest signatures and a caller-provided rollback counter (rullst-iot/src/ota.rs) | TM-IOT-1 |
| Contributor and CI ↔ repository; tag ↔ artifact ↔ registry | Pull requests, dependencies, build runners | Protected branches with required status checks and no bypass actors, pinned Actions, release admission of the exact SHA, attestations and reproducible archives (verifying a release) | TM-DEPLOY-1 |
| CLI ↔ filesystem and processes | Project names, paths, templates | Identifier and path validation in generators; fixed programs with argument arrays | TM-DEPLOY-1 |
Secure design principles
The argument follows Saltzer and Schroeder’s principles.
| Principle | How it is applied | Evidence |
|---|---|---|
| Least privilege | Workflow tokens default to read-only access (contents: read). In the release workflow only attest may write attestations, only publish gets a registry identity, and only github_release may write repository contents. API tokens carry an exact scope list without wildcards or implied parent scopes. | release.yml, scoped API tokens |
| Fail-safe defaults | Optional subsystems are off by default (default = [] in Core, Security and Auth). Production webhook state rejects mock_* verifiers. Documented placeholder APP_KEY values and weak keys are refused. The CORS scaffold fails on a missing or * origin. Image fetches for AI use a deny-by-default egress policy. | rullst-capital/src/webhook.rs (WebhookMiddlewareState::production), rullst-auth/src/auth/app_key.rs, CORS advisory, rullst-ai/src/ai/egress.rs |
| Complete mediation | Server applies the security baseline (headers, CORS, WAF, CSRF) to the whole router in staging and production, not per route. Every parameterized route must carry a // rullst-access: classification, and cargo rullst audit --idor fails when one is missing. | rullst-core/src/server/stack.rs, rullst-core/src/security/baseline.rs, access contract |
| Economy of mechanism | Twenty focused crates with feature-gated optional parts; one escaping path for html!; one canonical middleware order; source files target 500 lines. Maturity tiers keep experimental code out of the core promise. | maturity tiers, .github/check-crate-architecture.sh |
| Separation of privilege | Releases pass through separate jobs: admit the exact SHA, verify, attest without executing source, reproduce, then publish from a protected crates-io environment that requires a reviewer. Branch rules have no bypass actors. | release.yml, governance |
| Least common mechanism | Tenant data is scoped per request through TenantContext and ORM tenant columns, with fail-closed checks, instead of one shared unscoped query path. Studio and the debug consoles are separate mechanisms, mounted only in debug Development builds and reachable only from loopback. | spec, tenant scope contract, threat models |
| Psychological acceptability | Secure behavior is the default path: cargo rullst new scaffolds the baseline, cargo rullst doctor reports a security baseline, cargo rullst audit checks routes, and html! escapes without extra calls. | CLI reference, which security layer to use |
| Open design | The source, threat models, claims ledger and audit kit are public. Security relies on keys and verified controls, not on secret designs. | threat models, v12 claims ledger, external audit kit |
Countering common implementation weaknesses
The table maps OWASP Top 10 and CWE Top 25 style weaknesses to the mechanism and to tests that fail if the mechanism regresses.
| Weakness | Mechanism | Tests | Limits |
|---|---|---|---|
| SQL injection (CWE-89) | The ORM binds values as parameters. Column names pass validate_identifier (rullst-orm/src/schema/validation.rs); operators come from an allowlist. Nexus and Studio use sanitize_identifier. | rullst-orm/src/schema/tests.rs::test_validate_identifier, rullst-orm/tests/builder_validation_test.rs::joins_and_vector_helpers_reject_dynamic_sql_fragments_that_are_not_safe, rullst-studio/src/data_browser/tests.rs::test_sanitize_identifier; Kani proofs for sanitize_identifier | where_raw, or_where_raw and select_raw take caller-written SQL. |
| Path traversal (CWE-22) and OS command injection (CWE-78) | validate_relative_path (rullst-core/src/storage.rs) and cloud validate_key reject .., absolute paths and control characters; static files accept only plain paths. The CLI runs fixed programs with argument arrays and validates names as Rust identifiers. | rullst-core/src/storage.rs::relative_path_validation_rejects_escape_attempts, rullst-core/src/storage.rs::local_storage_rejects_symlink_escape, rullst-core/src/storage/cloud/tests.rs::every_cloud_operation_rejects_unsafe_or_ambiguous_keys, rullst-core/src/server/server_middleware.rs::only_plain_static_paths_are_probed | A tenant prefix does not authorize access to every object in that tenant. |
| Cross-site scripting (CWE-79) | html! (rullst-macros/src/html_parser.rs, attributes in html_attributes.rs) escapes every interpolation, including conditional name?={value} attributes, neutralizes javascript: and vbscript: URLs and rejects dynamic or conditional event-handler attributes at compile time. Nexus escapes stored values. A per-response CSP nonce is set by the header layer. | rullst-core/src/html_macro_tests.rs::dynamic_url_attributes_neutralize_script_schemes, rullst-core/src/html_syntax_tests.rs::conditional_url_attributes_keep_the_scheme_check, compile-fail fixtures rullst-macros/tests/ui/dynamic_event_handler.rs and conditional_event_handler.rs, rullst-nexus/tests/renderer_escaping.rs::stored_cells_remain_text_including_entity_prefixes, rullst-security/src/headers.rs::nonce_is_available_to_renderer_and_matches_header | RawHtml, custom escaping, JavaScript contexts and dynamic style values stay the caller’s responsibility. |
| Cross-site request forgery (CWE-352) and cross-site WebSocket hijacking | Double-submit cookie middleware (rullst-core/src/security/csrf.rs) with constant-time comparison and Secure cookies in production; an Origin allowlist for WebSocket upgrades (rullst-security/src/cswsh.rs). | rullst-core/src/security/csrf_tests.rs::empty_or_ambiguous_csrf_proofs_do_not_reach_the_handler, rullst-core/src/security/csrf_tests.rs::production_like_environment_sets_secure_cookie, rullst-security/src/cswsh.rs::middleware_rejects_a_deceptive_localhost_origin | The unsigned double-submit scheme depends on cookie isolation between sites; no blueprint mounts the CSWSH layer by default. |
| Broken authentication and session management (CWE-287, CWE-916, CWE-307) | Argon2id password hashing off the async runtime, with a dummy verification against user enumeration (rullst-auth/src/auth/password.rs). Cookie sessions are AES-256-GCM encrypted and HttpOnly. LoginGuard adds progressive delay and temporary bans. Passkeys check presence and monotonic counters. Webhook signatures use constant-time comparison. | rullst-auth/tests/integration_test.rs::test_argon2_password_hashing_and_verification_async, rullst-security/src/login_guard/capacity_tests.rs::identity_flood_cannot_disable_the_jail_for_a_later_identity, rullst-auth/src/auth/passkey/invariant_tests.rs::assertions_require_presence_verification_and_monotonic_counters, rullst-capital/tests/stripe_event_envelope.rs::invalid_signatures_are_rejected_and_mock_events_cannot_pass_real_mode_gate | Cookie-session inventory and refresh remain open (AUTH-06). The login jail is process-local. |
| Broken access control and IDOR (CWE-639, CWE-862, CWE-863) | RbacGuard::authorize_owner_or_role and its tenant variant, #[require_role], ORM tenant scoping, and the route classification enforced by cargo rullst audit --idor on pull requests and releases. | rullst-security/tests/security_tests.rs::parameterized_route_denies_cross_owner_access_before_the_handler_succeeds, rullst-security/src/rbac/guard.rs::tenant_authorization_never_allows_role_bypass, rullst/tests/rbac_macro.rs::role_attribute_preserves_arguments_and_denies_before_the_handler, rullst-orm/tests/builder_safety_audit_test.rs::tenant_scope_cannot_be_bypassed_by_or_filters, cargo-rullst/src/generators/audit_idor.rs::owner_routes_require_the_owner_or_role_guard | The IDOR scanner is a heuristic; it cannot prove domain authorization correct. |
| Server-side request forgery (CWE-918) | EgressPolicy (rullst-ai/src/ai/egress.rs) and outgoing webhooks resolve DNS once, pin the addresses and refuse private, special and mixed results. AI provider URLs must use HTTPS, or a literal loopback address for local models. | rullst-ai/src/ai/egress.rs::strict_policy_rejects_ssrf_destination_forms, rullst-ai/src/ai/egress_fetch.rs::fetcher_rejects_private_or_mixed_dns_before_transport, rullst-messaging/src/webhooks/transport.rs::private_special_transition_and_mixed_dns_addresses_are_denied | OIDC discovery requires HTTPS but does not block private addresses. These checks do not replace a network firewall. |
| Unsafe deserialization and resource exhaustion (CWE-502, CWE-400, CWE-770) | Input is parsed into typed serde structures, never into executable objects. ValidatedJson and ValidatedForm add validation (rullst-core/src/validation.rs). The JSON schema guard caps payloads at 2 MiB and nesting at depth 32 and rejects duplicate keys. 42 fuzz targets cover parsers and protocol inputs. | rullst-security/src/schema_guard/tests.rs::middleware_rejects_deep_and_oversized_json, rullst-core/src/validation_tests.rs::extraction_failures_keep_their_413_and_415_status; targets in .github/fuzz-targets.json | Upload routes need an explicit body limit; fuzz campaigns run on demand, not per pull request. |
| Memory-safety errors (CWE-787, CWE-416, CWE-125) | Rust’s ownership rules. unsafe-policy.yml compiles the workspace with -D unsafe-code and allows unsafe only in a fixed list of five OS-integration files (Windows ACLs, Unix permissions, the development dynamic-library loader and process telemetry). Four crates use #![forbid(unsafe_code)]. | Nightly AddressSanitizer and ThreadSanitizer (sanitizers.yml), Miri (miri.yml), 22 Kani harnesses (kani.yml) | Miri and Kani run weekly and fuzzing is started by hand. The allowlisted files hold about 50 unsafe blocks, mostly Windows FFI that Linux sanitizers do not exercise. |
| Secrets exposure (CWE-798, CWE-312, CWE-532) | Keys come from the environment or configuration, never from source. VaultSecret zeroizes on drop and redacts Debug. FieldEncryptor and ORM field encryption use AES-256-GCM with key IDs for rotation. The DLP layer masks a bounded set of key formats in responses. TruffleHog scans the repository. | rullst-security/src/vault/tests.rs::test_vault_secret_redaction, rullst-security/src/vault/tests.rs::supports_key_rotation_with_envelope_key_ids, rullst-auth/tests/app_key_resolution.rs::documented_placeholder_app_keys_are_rejected, rullst-auth/tests/scaffold_app_key.rs::public_env_example_key_cannot_encrypt_or_decrypt_sessions, rullst-core/src/validation_tests.rs::display_and_debug_never_contain_the_rejected_values | DLP recognizes only listed formats (not JWTs or generic API tokens). Applications that used public example keys must rotate them. |
| Weak cryptography and certificate validation (CWE-327, CWE-295) | AES-256-GCM, HMAC-SHA256, Ed25519, ECDSA P-256 and Argon2id. TLS uses rustls only (TLS 1.2 and 1.3); no first-party code disables certificate verification, and remote PostgreSQL requires sslmode=verify-full. | rullst-auth/tests/recovery_contract.rs::postgres_urls_use_the_hardened_connection_policy_before_connecting; CodeQL and cargo audit after every push to main | HMAC-SHA1 is used only for TOTP, as RFC 6238 and authenticator apps require. JWTs support HS256 only. |
Security headers add defense in depth: HSTS, a nonce-based CSP,
X-Frame-Options: DENY, nosniff, COOP, COEP, CORP and a Permissions-Policy
(rullst-core/src/security/headers.rs, tested by
rullst-core/src/security/headers_tests.rs::unmarked_responses_get_every_default).
How the argument is kept true
- Per change: formatting, Clippy with
-D warnings, the Linuxworkspaceandcli-standardtest shards,cargo audit,cargo deny, the unsafe policy, the zero-panic Clippy gates, the crate-archive reproducibility check and a mutation check of the changed Rust lines (mutants-pr.yml) run on pull requests tomain. The push after a merge tomainadds every Linux shard, the threat-model release minimum, coverage, SemVer checks and CodeQL, and the nightly run covers Linux, macOS and Windows. CodeQL analyzes Rust and JavaScript/TypeScript after every push tomainand weekly. - Coverage: line coverage must stay at or above 90% (
coverage.yml, after every push tomain);mainmeasured 93.96% on 29 October 2026 (run 40865935687). - Scheduled and on demand: nightly sanitizers, weekly Miri and Kani, and manual fuzzing and mutation campaigns. See the workflow guide.
- Releases: the exact tagged SHA must pass admission; artifacts are attested with Sigstore and archives are reproduced before publication. See verifying a release and reproducible crate archives.
Limits
- Application-owned controls. Session validation, authentication, tenant membership and object authorization have slots in the canonical order, but the application must mount them. The framework cannot infer those policies.
- Open abuse cases. The threat models mark items such as
AUTH-06(cookie-session inventory and refresh) as open. A passing release minimum does not close every case. - Heuristic layers. WAF, RASP, DLP and the IDOR scanner reduce risk but can be bypassed or miss findings. They do not replace typed parsing, parameterized SQL or authorization.
- Single-instance state. Login jails, replay caches, rate limits and audit buffers are process-local unless a shared backend is configured.
- Defaults depend on environment. The Core security baseline applies in staging and production. Development builds and custom routers must not be exposed publicly.
- Analysis cadence. Fuzzing runs on demand and Miri and Kani weekly, so a regression between runs is caught by tests and sanitizers, not by those tools.
- Review. One active maintainer reviews changes. AI-assisted review and automated checks are not independent human review, and no external audit has been completed.
- Native binaries. CLI executables are attested but not claimed to be bit-for-bit reproducible.
v12 security claims and evidence ledger
Historical (v12). This ledger records the claims of the v12 release line, which reached end of life on 16 October 2026; it is not updated for v13. For v13, see the security assurance case, the threat models (TM-13.0) and the machine-readable
.github/threat-model-release-minimum.json.
This page is the canonical boundary for security claims made by the Rullst v12 release notes. Each row names the narrow behavior implemented in the repository, the tests that exercise it, and a known limit. A security statement that is not represented here is not a v12 release guarantee.
The evidence links point to source-controlled tests; links to files that v13
removed point to the v12.1.2 tag. The other links open the current main
source, which may have changed since v12; the frozen v12 branch holds the
code these claims describe. A passing local run proves only that
checkout and environment; the release workflow attaches the same
checks to the immutable tag SHA. None of these rows is a certification, external
audit, proof of complete OWASP coverage, or substitute for an application’s own
threat model.
v13 removed the Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay,
Coinbase Commerce, PicPay and Wise adapters, the payout contracts and the NFS-e
preparation from rullst-capital, and make:mail-invoice now generates a
receipt-only template. Rows that describe those surfaces remain v12 claims and
link to the v12.1.2 tag; see the v13 migration guide.
| Narrow v12 claim | Implementation | Verification | Known boundary |
|---|---|---|---|
The optional Connect Axum session flow binds one authorization callback to ten-minute state + PKCE and, for OIDC, nonce held in tower-sessions; it removes and immediately saves the challenge before validation and does not print its URL, code, nonce or verifier through managed Debug implementations. | Managed session flow and constant-time callback state comparison | Round-trip, sequential replay, expiry, mismatch, missing-state, replacement and redaction negatives, promoted to TM-AUTH-04 in the release minimum | One challenge per browser session invalidates an earlier concurrent tab. The generic session-store contract is not a distributed compare-and-delete across requests that already loaded the same record. The host still owns durable/shared session storage, Secure/HttpOnly/SameSite cookie and TLS/proxy policy, redirect registration, post-login rotation, idempotent account linking/recovery and live-provider conformance. |
| The explicitly mounted Connect Mock IdP supplies a bounded signed OIDC loopback fixture: exact configured HTTP-loopback issuer/client/callback, expiring one-shot code, optional S256 PKCE and nonce, EdDSA ID token/JWKS and issued-bearer userinfo. | Concrete router/state, validated configuration and fixture signer | Real OidcProvider loopback discovery/exchange/signature/replay test and local negative matrix, plus router integration | The signing seed and credentials are public deterministic fixtures. The state is bounded and process-local. This is not safe for public exposure and does not implement interactive login/consent, refresh/device/federation lifecycle, durable state, key rotation or OIDC conformance. |
Nexus requires an explicit access policy and rejects an unverified peer; registered CRUD metadata and submitted semantic fields also fail closed before dynamic SQL. An opt-in registered text tenant column scopes every built-in read/mutation/batch path to a trusted Core TenantContext, and required mutation audit couples one minimized record to the data transaction. | NexusAuthPolicy, registry validation, bounded form validation, scoped mutations, scoped batch and audit schema/API | Access-policy unit tests, metadata negatives, semantic validation negatives, HTTP widget/parameter-pollution regression, and materialized cross-tenant/audit atomicity regression, promoted as TM-NEXUS-02/TM-NEXUS-06 through portable release-minimum tests | Loopback convenience is debug-only and Basic Auth requires verified TLS. Unscoped models remain global. The application owns identity/membership/domain policy, within-tenant ownership, custom-route authorization, database schema/privileges and audit export. The built-in audit table is same-database mutable committed-event evidence, not denied-attempt, append-only, tamper-evident, retention, backup, replication or external-review evidence; generated create keys may be absent. |
Studio’s built-in local capability accepts only a verified loopback peer and local Host in debug builds, and unsafe methods require a same-origin Origin. | LocalStudioAccess | Inline loopback, remote-peer, missing-peer, Host and Origin negative tests, plus router integration tests | This bounds the supported local browser surface against direct remote access, DNS rebinding and cross-origin mutation. Shared or release deployments still need application-owned authentication, administrator authorization, TLS and network policy; Studio does not supply them. |
| Newly generated CORS middleware fails closed without a valid explicit origin allowlist. | CORS scaffold template | Generated-middleware HTTP tests | Previously generated applications are copied code and are not rewritten by a CLI upgrade. Browser/proxy behavior must still be tested in the deployed application. |
Newly generated fiscal-receipt mail keeps an OfflineMock visibly unauthorized, rejects contradictory fiscal markers, and both fiscal/dunning action links require credential-free HTTP(S) URLs. | Extended mail scaffold templates, validate_action_url, and CLI dispatch | Materialized seven-template Clippy/runtime/provenance/link/collision contract and action-URL unit negatives | Generated source cannot prove an application-supplied response truthful, authorize tax documents, calculate due state, schedule delivery, pause access, or define billing policy. Previously generated source is not rewritten. |
| The opt-in Mail inspection guard completes every configured static inspection before transport and fails closed on scanner unavailability. Its strict local policy rejects executable magic, spoofed recognized types, active PDF/SVG, recognized secrets and unsafe links. | Inspector contract and guard | Safe-shape and active/type-confusion negatives, promoted to TM-MAIL-01 in the release minimum | The local policy is a bounded heuristic, not antivirus, sandbox execution, recursive archive inspection or CDR. Hosts that require authoritative scanning must provide and operate a production AttachmentInspector. |
| The opt-in Mail suppression guard checks a recipient before transport and fails closed when suppression state is unavailable. The SQLite store binds provider/event/payload replay exactly, escalates manual/hard-bounce/spam-complaint state monotonically and enforces immutable quotas across restart and competing local instances. | Suppression domain and guard and SQLite store | Process-local contracts and restart, two-instance quota, conflict, pruning, target and corruption negatives, promoted to TM-MAIL-02 in the release minimum | SuppressionEvent does not authenticate a webhook. Only already verified provider events may be recorded. Replay retention must cover provider redelivery; file protection/encryption/backups, multi-host replication and provider conformance remain external. |
| Mail terminal observations exclude recipient, subject, body, filenames and provider response content. The default sink is bounded and exposes eviction count, while observer failure cannot turn an accepted delivery into a retryable error. | Observation contract and wrapper | Outcome, minimization, capacity, tenant and label regressions, promoted to TM-MAIL-03 in the release minimum | The default sink is process-local. External tracing/metrics delivery, retention, correlation and alerting remain deployment work; a custom sink must not block or panic. |
| Newly generated billing code selects SQLx or Turso-primary persistence, derives checkout/portal identity from an authenticated extension, requires an explicit production plan allowlist before persistence, accepts webhook events only through the mandatory verifier layer, refuses existing outputs and rejects cross-owner subscription reuse before binding the conflicting customer. | Billing generator and generated controller | Materialized SQLite/Turso Clippy, migration, persistence, ownership and collision contract, promoted under TM-DEPLOY-06 | Generated code is application code. The host must configure correct provider plan IDs, mount auth and exact webhook middleware, add durable provider-event idempotency/reconciliation, validate a provider sandbox and review previously generated source separately. |
Core CSRF middleware uses a double-submit check, only exact configured webhook exemptions, and request-idempotent nested composition so an application layer wrapped by the production Server baseline emits one matching token/cookie pair. | CSRF middleware | CSRF unit tests, including the nested-layer GET/POST regression, and application integration tests | Safe deployment still depends on HTTPS, cookie configuration, application authentication, and a correct exact-path exemption list. Request-body inspection is bounded; idempotence does not make the double-submit cookie an authentication credential. |
| Core WAF inspects and preserves supported bounded request bodies and rejects bodies outside its one-megabyte inspection envelope. | WAF middleware | WAF body and query tests | Detection is pattern-based, has false positives and negatives, and is not complete OWASP coverage. Streaming or other uninspected representations require application controls. |
| Parameterized routes recognized by the CLI audit require an adjacent public/owner/role/admin classification; owner paths require the recognized ownership guard. | Bounded route source audit and RbacGuard | Audit negatives, scaffold contracts, and HTTP owner/cross-owner test | Static analysis recognizes bounded syntax and guard names; it cannot prove the correctness of an application’s identity source, object lookup, or business authorization. |
| Encrypted session tokens are versioned, authenticated with AES-256-GCM, expire, and reject malformed or weak-key inputs. | Session implementation | Session negative and round-trip tests and integration tests | Application-key custody, rotation, session/device revocation, and distributed logout are application or roadmap responsibilities. |
The optional application JWT policy binds versioned access claims to issuer, audience, subject, times, token ID and session version; it bounds TTL/scopes and supports strong kid key rotation. The SQLite profile serializes bounded JTI/session-version revocation shared by local processes and verifies it asynchronously. | JWT policy and SQLite revocation store | Rotation, revocation, audience, configuration and production fail-closed tests, plus restart, exact quota, two-instance and corruption evidence | The bundled in-memory store remains process-local and is rejected by production mode. SQLite is one shared local file, not multi-host replication, secret management, backup/availability or a refresh-token workflow. Third-party OIDC verification belongs to Connect. |
| MFA recovery helpers generate subject-bound 80-bit codes and persist only salted HMAC-SHA256 verifiers; verification is constant-time and a successful in-memory consume removes one verifier. | Recovery-code implementation | Subject binding, single-use, plaintext-storage, format, pepper and tamper tests | The application must render plaintext once, protect the pepper, persist compare-and-delete transactionally, rate-limit attempts and audit enrollment/recovery. This is not a complete account-recovery workflow. |
| TOTP enrollment uses 160 bits from the OS RNG, rejects weak/invalid inputs, compares exactly six ASCII digits in constant time and can render a bounded SVG QR. | TOTP and QR implementation | Secret, code, drift, invalid-input and real-SVG tests | The application must encrypt the TOTP secret, protect the enrollment ceremony, confirm a code before activation, rate-limit verification and own account recovery. |
The optional Redis rate limiter hashes client-derived keys and atomically shares a bounded fixed-window counter across independent clients. Empty or mock_* configuration is explicitly process-local and rejected by the production boundary. | Redis rate limiter | Offline fail-closed regression, live independent-client contract, and digest-pinned services in CI, which release admission requires at the exact SHA | A single Redis service does not prove cluster/failover behavior, Academy HTTP identity/origin composition, or an operational abuse policy. |
Core can bind its storage, cache and in-process realtime/presence facades to a tenant selected by authenticated membership. TenantStorage applies an immutable tenants/<tenant>/ object root after strict path validation; TenantCache validates logical keys, applies an immutable backend namespace and exposes no global flush; TenantRealtime/TenantPresence validate logical names, cap message payloads at 64 KiB and namespace rooms. | TenantStorage, TenantCache, realtime wrappers and TenantContext | Exact same-logical-name cross-tenant non-interference tests for storage, cache, broadcast and presence rooms in realtime (TM-TENANT-04) | These prove local wrapper contracts, not room-level authorization, distributed realtime/presence liveness, remote bucket policy, search/metrics/export isolation or automatic use by Academy/application routes. Redis cluster/failover remains separate evidence. |
The LMS starter persists schools, active/default memberships, course scopes, cohorts and temporal entitlements. Authentication treats X-School-ID only as a selector against persisted membership, binds the validated tenant to UserContext, and loads roles for that school. Learning, publication/rollback, assignment grading/correction, score correction/leaderboard, completion/certificate mutation, role lifecycle and scheduled publication enforce the selected school. Outbox, derived automation and notification state preserve school_id; the leaderboard cache is tenant-scoped, payload-validated and invalidated after authoritative mutations. | Tenancy models/migration/service, authentication and learning composition, leaderboard/cache service, and tenant-aware RBAC | Materialized arbitrary/ambiguous selection, entitlement, cache/invalidation, same-user notification/preference separation, foreign-rule isolation, cross-school HTTP enrollment/leaderboard and foreign-admin mutation/scheduler/role non-interference, plus exact tenant guard tests | This is bounded SQLite database/HTTP/local-cache evidence, not complete multi-tenancy. Other caches, files, search, metrics, exports, Nexus, invite/provisioning, distributed cache/failover and PostgreSQL/MySQL non-interference remain open. |
| The Academy scaffold provides a minimized, school-scoped privacy lifecycle foundation: age band instead of birth date, versioned bounded retention, purpose-specific guardian consent/revocation and idempotent export/delete request records. Minor authorization fails closed without matching active consent. A bounded admin/owner sweep schedules replay-safe delete requests; fulfillment adds exact leased claims, abandoned-claim recovery, delayed retry/dead-letter, a hard ten-attempt claim ceiling and actor/SHA-256-bound completion. A supervised static-dispatch executor applies an adapter timeout shorter than the lease, explicit shutdown and local metrics; its deterministic mock never exports, deletes or anonymizes data. | Privacy migration and service templates, retention scheduler, request worker protocol, and supervised executor boundary | Materialized consent, cross-school, stale-token, lease-recovery, retry/dead-letter, hard-limit, completion-digest, supervised-success and adapter-failure regression (TM-ACADEMY-10) | No product adapter that performs cross-table export/deletion/anonymization, legal-guardian verification, PII-safe application telemetry proof or legal compliance claim is provided. |
| Core’s storage-agnostic upload admission contract enforces a hard in-memory size ceiling and kind allowlist, validates canonical tenant/name and exact declared MIME/extension against recognized signatures, denies recognized active text, emits randomized tenant-prefixed quarantine keys and a SHA-256 digest, and fails closed unless a scanner adapter supplies a valid clean verdict. | Upload admission/quarantine contract | Exact unit negatives for active SVG/HTML text (including BOM-, XML-prolog- and comment-prefixed markup), traversal, tenant injection and MIME/extension spoofing, plus digest-bound clean/infected/unavailable mock scans in the same module | This is not an HTTP multipart implementation, deep parser, S3/R2 mover, transcoder or production malware scanner. PDF/media checks are bounded signatures, the bundled scanner is mock-only, and archives/SVG/HTML remain denied or outside the allowlist. |
| The LMS outbox commits score, first lesson completion, enrollment, achievement, publication, editorial rollback, assignment submission/grading/correction, course completion and certificate revocation events with domain state, claims an available event under a bounded lease, binds acknowledgement/failure to an exact token, schedules bounded retry, recovers expired claims, counts attempts and dead-letters at a bound. Its supervised database worker validates each supported envelope, loads current rules, rederives/applies the sole achievement action and ACKs/fails the exact claim; configuration, shutdown, safe drop and local counters are explicit. | Generated score/outbox templates, assignment services, progress/completion services, claim service, worker, closed passive envelopes, and executor | Materialized SQLite supervised worker/FIFO delivery, assignment/rollback/completion/revocation delivery, progress, score, backoff, expired-claim/stale-token, ACK-loss redelivery, transactional achievement and poison dead-letter regression | No external transport or exported durable telemetry is built in. Only score_recorded → award_achievement currently drives an automation mutation; tenant policy and approval envelopes are required before adding sensitive actions. |
The LMS starter grades single-choice quizzes from server-side question/option state, binds replay to attempt/quiz/learner/ruleset, enforces the configured attempt count, and commits immutable answers, a ScoreEvent, the authoritative leaderboard update, score_recorded and quiz_graded in one transaction. Timed quizzes require a persisted server-time start/expiry session and server-random question/option presentation; replay cannot extend/change either, starting consumes an attempt and grading rejects an ID set changed under the same ruleset. Authenticated start/submit routes derive subject/quiz from session/path and never accept client points. | Generated grading, timing ceremony, HTTP boundary and schema/models | Materialized SQLite correct/incorrect grade, start/order replay/expiry, cross-user, unknown-option, attempt-limit, score-event/outbox counts and leaderboard regression | Quiz grade override, browser UI and multi-database contention proof remain open; distributed clock and concurrent-start behavior remain deployment/test boundaries. |
The complete LMS starter’s owner-only activity routes accept only an idempotency key plus one selected option, a complete bounded pair-ID permutation or bounded typed text. Static-dispatch single-choice/matching/typed evaluators and opaque ValidatedActivityResult keep identity, answers, points, policy, evidence and time server-owned. Typed replay uses a policy-bound SHA-256 digest rather than raw text. A transaction-locked recheck binds exact evaluator configuration and atomically stores the exact-replay attempt, appends ScoreEvent v2, updates the leaderboard and emits strict score_recorded v2. Attempt identity is learner/activity-scoped and event identity is server-derived. | Activity evaluation/HTTP templates, matching evaluator/route, typed evaluator/route, persisted bridge, replay gate, score service and schema | Materialized SQLite HTTP owner/cross-user, actor/evidence/policy mismatch, malformed pair/text input, normalized/conflicting replay, raw-text absence, durable attempt, score/outbox/leaderboard and downstream automation regression | Typed comparison does not provide Unicode normalization/accent/fuzzy semantics and its digest may remain personal data. Listening/game evaluators, quiz-path unification and PostgreSQL/MySQL contention proof remain open. |
| Enabled Academy activities update a versioned deterministic review schedule in the same transaction as a newly applied authoritative score. Policy/state validation and algorithm drift fail closed; exact replay returns before the schedule transition. The bounded owner-only due endpoint derives learner/time server-side and rechecks school membership, course scope and active enrollment. | Review policy/state/service and HTTP templates, score transaction, routing and schema | Pure deterministic pass/lapse transition test and materialized SQLite three-activity persistence, replay, future queue and cross-user boundary | rullst-box-v1 is an inspectable bounded heuristic, not FSRS/SM-2 compatibility, educational efficacy, AI personalization or generated pedagogy. Algorithm migration/experimentation, complete privacy integration, visual UX and PostgreSQL/MySQL contention remain open. |
| Text assignments persist versioned server policy, rubric criteria, owner-bound submissions, human grades and criterion feedback. Submission requires the authenticated learner’s school-scoped enrollment, server deadline/attempt limit and exact idempotency request. Grading/correction derive the lesson scope from persisted assignment state, require the selected school and then apply evaluator/admin/rubric policy. Admin correction is append-only and records before/after, reason, actor, server time, canonical request and outbox. | Assignment models, submission service, grading service, correction service, schema, and HTTP boundary | Materialized deadline, cross-user, replay, impossible-score and correction regression, plus foreign-school grading denial | There is no attachment pipeline, visual authoring/review UI, tenant-scoped outbox or PostgreSQL/MySQL contention proof. Human evaluator identity is only as strong as the host authentication/role lifecycle. |
| Protected LMS lesson, progress and assessment paths require the authenticated school, an active membership, a course scope, open-or-active entitlement policy, active enrollment and exactly one valid versioned availability rule; server time enforces release/expiry and same-course prerequisites. | Generated learning guard, school service, and availability schema/model | Materialized prerequisite/release/expiry regression and cross-school HTTP/database denial plus entitlement regression | Entitlement issuance is not connected to billing, and signed media/download URLs, cross-subsystem tenant isolation and multi-database contention remain open. |
Academy course completion is derived only after school-scoped membership/entitlement checks and from the immutable version pinned at enrollment plus persisted lesson progress under a closed rule. The transaction writes immutable evidence, one opaque certificate and course_completed; public verification omits learner identity, while revocation requires an admin in the certificate’s school, server time, reason and exact idempotency. | Completion service, models/schema, and HTTP boundary | Materialized completion/revocation regression and foreign-school revocation denial | Certificates are database-verifiable opaque records, not externally signed credentials. Portable signatures, full relational snapshots, tenant-scoped outbox, PostgreSQL/MySQL contention and an external revocation registry remain open. |
A newly applied Academy achievement emits school-bound achievement_awarded in the same transaction; the exact claimed event creates one localized in-app notification in that school, applies school/user opt-out/locale state, and authenticated owner/admin APIs control bounded school-filtered listing, preference updates and idempotent read transition without accepting subject identity from input. Its closed versioned template renders Portuguese, Spanish or English with deterministic English fallback and rejects unknown keys/payloads. A new unsuppressed row and the same rendered projection are sent after commit through TenantRealtime to an authenticated tenant/user subscription. | Transactional achievement event, notification contract/schema/realtime projection, closed translation templates, HTTP boundary, and worker dispatch | Materialized database delivery, three-locale/fallback rendering, unknown-key denial, realtime receipt, owner/cross-user listing/read/preference/replay and same-user cross-school notification/preference isolation | The database is authoritative; realtime is in-process and best-effort. Catalogs for other event kinds, Mail/push, distributed realtime/replay, tenant-scoped Nexus UI and PostgreSQL/MySQL contention remain open. |
| Course versions are immutable snapshots with explicit draft/review/scheduled/published transitions, an admin reviewer distinct from the author, atomic archival/publication plus outbox, and an enrollment-time pin that does not move when a newer version publishes. Authenticated handlers derive the author/reviewer and current time from server state rather than payload fields. A supervised bounded scheduler acquires/renews/releases an exact shared lease, activates due versions with an admin context, preserves the independent reviewer, records the activation actor and fails closed on lease loss. Admin-only editorial rollback rejects the source author, requires reason/idempotency, creates a new published revision from historical content and atomically persists replaced/result version evidence plus outbox without moving old pins. | Publication model/service/migration, rollback service/model/migration, HTTP boundaries, scheduler loop and transactional enrollment pin | Materialized service/pin regression, rollback authorization/replay/conflict/audit and old/new pin regression, and handler plus supervised scheduler contention/activation/replay continuation | The starter does not yet serve every module/lesson/assessment field from the snapshot, expose visual Nexus authoring, export scheduler telemetry to Studio, or prove PostgreSQL/MySQL contention. |
Education roles are durable, allowlisted and reconstructed from active assignments for the authenticated school; each grant stores school_id, actor, reason and validity window, support must expire, and only a distinct school owner can grant or revoke owner/admin. Exact grant/revocation replay is a no-op only for the same request; cross-school assignment lookup returns not-found. | Role model/service/migration, HTTP boundary and authentication composition | Materialized expiry/grant/revoke/replay negatives and foreign-school revocation denial | There is no explicit step-up ceremony, external audit sink or AI tool integration. Provisioning/bootstrap and full Nexus isolation remain application-owned. |
| Academy scheduler instances sharing a database coordinate on a named lease with exact holder/token renewal and release; another token can take over only at expiry. Scheduled publication mounts that lease around every bounded cycle, renews before each mutation and provides an awaited shutdown, safe drop and local counters. | Generated scheduler lease model/service/migration and supervised publication scheduler | Materialized competing acquire, wrong-token renewal, pre/post-expiry takeover and stale-release regression, plus continuous scheduler standby, activation and empty replay | Delay/failure metrics are not exported to Studio or a durable backend. PostgreSQL/MySQL contention and distributed clock-topology behavior remain unproved. |
| Automatic OAuth refresh validates and redacts token state, binds every replacement to the original provider user, serializes concurrent process-local refresh and swaps credentials only after a complete response with a bounded lifetime. A storage-neutral AES-256-GCM snapshot preserves the validated generation and authenticates the version, key ID, provider and trusted local-account binding before restoration. The optional SQLite store adds a persisted quota, restart recovery and exact generation CAS for shared-local writers. | Refresh coordinator and values, encrypted snapshot and SQLite store | Fresh, concurrent single-refresh, rotation, identity-confusion, malformed-response and rollback tests, snapshot unit negatives, public storage/ownership contract and the TM-CONNECT-24 two-instance/restart/corruption matrix | The host must authorize the account, provide high-entropy key custody/rotation, lease the remote provider call, reconcile a losing rotation, protect and back up the directory, configure retry/backoff/revocation and decide when reauthentication is mandatory. SQLite CAS is local durability, not a secret manager, remote refresh lock or multi-host replication contract. |
| The current passkey foundation binds ceremonies to relying-party/origin data, consumes bounded challenges once, verifies flags/signatures, and enforces monotonic counters. The SQLite profile persists bounded device registration/inventory/rename/revocation and advances counters with CAS shared by local processes. | Passkey service, ceremony store and SQLite device lifecycle | Invariant tests, negative-path tests, and real ES256 lifecycle, restart, replay, quota and two-instance CAS tests | Challenge state remains process-local, so multi-instance ceremonies require sticky routing or a host shared layer. It has not passed an external WebAuthn conformance suite or independent audit; SQLite file trust/encryption/backup/replication and device ownership remain deployment/application duties. |
| Capital exposes Axum and opt-in Actix middleware over one canonical verifier. Both bound payloads at two megabytes, verify the selected provider before dispatch, restore the exact signed body, insert the normalized event, check protocol freshness where defined, and reject replay within the configured store. | Canonical verifier and Axum adapter and Actix adapter | Stripe/LemonSqueezy canonical, freshness, reconstruction, capacity, expiry, Actix preservation/rejection and replay tests | The default replay store is process-local and payload-keyed. Multi-instance deployments require durable shared provider-event idempotency before side effects, provider-specific operational validation, and an append-only audit trail. |
A Billable direct charge requires bounded integer minor units, currency, provider-owned customer and tokenized payment-method IDs, validated model e-mail and an application idempotency key. Stripe forwards the key, confirms off-session and accepts only amount/currency-bound succeeded or processing; deterministic offline receipts are explicitly non-success Mock. | Charge contract, Billable helpers, and Stripe adapter | Validation/redaction/provider-negative integration, Stripe response-confusion negatives, and facade derive regression | The application must authorize provider IDs, establish mandate/SCA, persist unique keys/order state, reconcile signed events and drive entitlements. No other adapter or live sandbox is implied. |
| Metered usage uses provider-specific identities and actions rather than guessing from one uniform subscription ID. Stripe forwards and binds customer/event/value/timestamp/identifier; Lemon Squeezy sends and binds the subscription-item relationship, quantity and action while preserving a separate application event key. | Usage domain, Stripe adapter, and Lemon Squeezy adapter | Stripe exact HTTP/body-limit/mismatch/mock tests, Lemon exact JSON:API/mismatch/mock tests, and domain validation/redaction tests | The fixtures do not establish live-account acceptance. Stripe uniqueness is rolling, while Lemon’s request has no equivalent provider event key; the application must durably claim events, configure the matching aggregation, retry/reconcile and drive quota/entitlement state. |
| Coupon IDs and relative trial changes are bounded before provider dispatch. Stripe’s discount response must bind the subscription and expanded coupon; Stripe and Lemon Squeezy trial responses must bind subscription and exact expiration. Unreviewed live operations fail explicitly. | Subscription values, Stripe protocols, Lemon Squeezy trial protocol, and static handle | Exact local HTTP fixtures assert form/JSON:API headers, body and response binding; cross-provider fail-closed integration covers unreviewed live paths. | The host must authorize ownership, persist one command clock for stable retries, serialize conflicting updates, understand provider billing-cycle effects and reconcile signed webhooks. Protocol fixtures are not live-account acceptance. |
Shared Team/Workspace quotas use a trusted billing subject, an application-stable event key and an atomic conditional counter. An over-limit or exact replay never executes QuotaGate’s callback; a conflicting replay fails closed. The SQL adapter can reserve in the same transaction as the domain insert. | Quota domain and gate, SQL store, and Billable limit derivation | Local validation, isolation, replay, compensation and concurrency plus live PostgreSQL, MySQL, and MariaDB contention contracts | Authentication must establish membership and the active tenant; webhook reconciliation must persist the authoritative tier. Schema migrations, abandoned standalone reservation policy and Turso/NoSQL adapters remain host work. Writes outside the explicit gate are not intercepted. |
| Opt-in paid-invoice delivery rejects non-final/mock payment evidence and exact recipient, minor-unit amount or currency mismatch before producing bounded HTML/PDF; the Mail bridge runs pre-flight and retains a stable non-secret delivery key. | PaidInvoice binding, PDF renderer, and Mail bridge | Payment-status/substitution and key-stability negatives, font/pagination/PDF parse tests, and prepared plus explicit-driver delivery tests | A custom adapter is trusted to authenticate its own provider response. The application must construct the authoritative order, reconcile webhooks, atomically claim the key in a durable outbox and own retry/provider attachment policy. No automatic webhook hook, provider acceptance or exactly-once delivery is implied. |
| Vault field envelopes use authenticated AES-256-GCM, random nonces, version/key identifiers, AAD binding, and explicit rotation keys. | Vault implementation | Round-trip, tamper, wrong-key, malformed-envelope, and rotation tests | The caller owns key custody and rotation. No real KMS/HSM adapter or hardware-backed key claim is made. |
#[orm(encrypted)] transparently encrypts String and Option<String> values on generated writes, decrypts generated model reads, authenticates table/column context, and reads prior keys from an explicit keyring. Generated audit rows, committed events, Scout documents and save_to_redis hashes never carry encrypted or #[orm(masked)] plaintext. | ORM privacy implementation, derive integration and generated projections | End-to-end SQLite insert/read/update/rotation/tamper/query-boundary test, envelope unit tests, audit/event/Scout redaction test and projection/Redis hash test | Randomized fields cannot support generated filtering, ordering, grouping, or explicit selection. Raw SQL is not transformed, blind indexes are application-owned, full key retirement is not automated, and key custody remains external. |
| Portable document recovery encrypts and authenticates a versioned MongoDB/SurrealDB/deterministic-store snapshot with AES-256-GCM, fresh nonce, explicit rotation key and length-delimited application/collection binding. Export compares two bounded inventories; restore accepts only an empty or exact matching subset, never replaces/deletes and verifies the final collection. | Recovery types and redacted key/envelope, bounded codec, and restore algorithm | Tamper/key/scope, conflict/extra-row, replay, capacity, ordering and concurrent-source negatives, plus the live MongoDB→SurrealDB→MongoDB matrix | Applications must quiesce writers, provision destination schema and own high-entropy key custody, rotation, snapshot permissions, durability, retention and erasure. Two equal scans are not formal online snapshot isolation; partial successful inserts remain for exact retry, and no managed backup, point-in-time recovery, replication or cross-store transaction is claimed. |
| OTA accepts firmware for commit only after Ed25519 verification binds target, version, rollback counter, length, and SHA-256 digest. The store-backed path additionally requires exact monotonic compare-and-set before local mutation. | OTA manifest verifier and counter-store contract | OTA integration negatives and state tests, restart/retry/conflict store tests, and bounded fuzz target | Rullst does not download, flash, boot, or implement/certify a physical durable counter. Platform bootloader, storage integrity, wear and power-loss integration remain external. |
Core’s canonical browser baseline installs the exact application config outside a stable secure-header/CSP nonce → exact-origin CORS → WAF → CSRF → optional PII order; Server uses the same composition. Core and the optional extended secure-header layer reuse one per-request CSP nonce. Generated LMS/SaaS authentication plus LMS catalog/course/player style elements consume that nonce, and the audited LMS shell has no remote page dependency or inline style attribute. | Core baseline, generated authentication controller, LMS catalog (the LMS example since 13.0.0-alpha.4), and extended layer | Integrated Core baseline tests, generated catalog contracts, materialized escaping/nonce regression, and extended nonce tests | In-process/source evidence does not guarantee a scanner grade or real-browser behavior. TLS, proxy policy, every application-owned HTML surface and session/auth/tenant layers still require deployment testing. |
| Response DLP masks recognized secrets only in supported, bounded textual payloads without corrupting invalid UTF-8 or incomplete values. | DLP middleware | Unit/integration tests and bounded fuzz target | It does not prove zero leakage and does not rewrite arbitrary binary, compressed, encrypted, or streaming bodies. Unknown secret formats can pass through. |
| RASP rejects the implemented SQL injection, path traversal, JNDI, SSRF, RCE, header, and decoded JSON patterns within its inspection boundary. | RASP inspector | Middleware and pattern tests, integration tests, and bounded fuzz target | It is a bounded detector, not a parser for every protocol or proof against every bypass; false positives and false negatives remain possible. |
| Login Jail applies bounded progressive delay and temporary process-local jailing, with cleanup of expired state; its async API awaits the selected delay. | Login guard | Applied-delay, jail, expiry, capacity, and concurrency tests and contention tests | State is not distributed. Correct client identity depends on trusted peer/proxy handling, and deployments still need upstream abuse controls. |
| The optional Redis limiter consumes one namespaced fixed-window budget atomically, hashes the caller-supplied client key and returns TTL-derived retry metadata. | Redis limiter | Configuration, key-redaction, explicit-mock and shared-clone tests | Offline mode is process-local and require_distributed() rejects it. Real Redis cross-instance, eviction/failover and trusted-proxy deployment tests remain required; the caller still owns the identity key policy. |
| Audit-chain entries use HMAC verification and unambiguous length-prefixing; ordinary process-local telemetry is not presented as HMAC verified. | Audit chain, telemetry store, authenticated journal, and Nexus rendering | Tamper/concurrency tests, telemetry provenance tests, journal forgery/rotation tests, and Nexus badge test | The compatible DurableSiemSpool deliberately stores unsigned events. The separate opt-in journal sets the flag only after verifying its complete local HMAC chain; it does not authenticate the truth or identity behind a semantic event. Key management, trusted tail checkpoints, external storage/delivery and independent verification remain open. |
| Local security telemetry uses an exact, versioned v1 JSON envelope with normalized identifiers, IPs and timestamps, bounded UTF-8 details, escaped CEF extension values and opt-in synchronous local journals. | LiveSecurityEvent, machine-readable schema, CEF serializer, compatible spool, and authenticated journal | Schema/normalization tests, unsigned spool tests, authenticated restart/rotation/forgery/ordering tests, public API restart test, and CEF injection test | Both formats are bounded and single-process. The authenticated form supports explicit key rotation but not trusted whole-tail checkpoints, directory policy, compaction, delivery, acknowledgement, retention, correlation, retry, dead-letter handling, or an external SIEM. |
| Declared JSON bodies fail closed on invalid syntax, recursive duplicate keys, excessive size/depth and ambiguous content types; an explicitly mounted policy additionally enforces one bounded JSON Schema 2020-12 document or OpenAPI 3.1 component without external retrieval; recognized log secrets and local SRI assets have explicit bounded helpers. | Schema guard, compiled policy, log redactor, and SRI helpers | Inline malformed/duplicate/nested JSON, schema shape/external-reference/OpenAPI component negatives, repeated secret and file-backed SRI tests in those modules, plus DLP/RASP integration | This validates JSON bodies only after explicit per-route mounting. It does not infer arbitrary route schemas, authorize a subject, enforce domain rules, install a global tracing filter, discover every secret, or rewrite every asset automatically. |
| Security CLI evidence fails when an explicitly requested Geiger/SBOM/network check is incomplete or reports a finding; SBOM UUID/component fields and actual MSRV parsing are regression-tested. | Audit orchestration, SBOM/network evidence, doctor, and managed hook | Unit tests in the linked modules and strict package Clippy | Source heuristics are not complete static analysis; the SBOM is not signed attestation; listener observations are not external reachability; tool presence and local output are not certification. |
| AI prompt checks and PII masking run before built-in provider dispatch and report only a heuristic result. | GuardrailReport, provider dispatch, compatible adapter, and versioned corpus | Versioned cross-provider eval runner, cross-provider guardrail pipeline, compatible public/loopback contracts, and provider capability tests | The corpus is a deterministic regression suite, not a safety benchmark. Compatible capabilities are caller declarations, not discovery. Passing heuristics or JSON schema does not make output trustworthy, stop adaptive injection, authorize tools, prevent egress, or prove an upstream request was cancelled. |
| Every built-in live AI transport applies a configurable local request deadline, defaulting to 30 seconds. | OpenAI, OpenAI-compatible, Anthropic, Gemini, DeepSeek, and Ollama adapters | Capability contract and loopback timeout/shape regressions | A local timeout only drops the request future. It does not prove upstream cancellation, prevent billing, provide automatic retries, limit concurrency, or implement circuit breaking. |
| The strict AI egress policy denies every host until an exact allowlist is configured and rejects insecure/credentialed URLs, local/private/metadata/reserved addresses, mixed DNS answers, disallowed redirects and oversized bodies. Its opt-in fetcher resolves under deadline, pins all validated answers into a proxy-free client, verifies the connected peer, disables automatic redirects and enforces the byte budget while streaming. | EgressPolicy, host normalization, and EgressFetcher | IPv4, IPv6, allowlist, DNS, redirect, configuration and resource-budget negatives, plus private/mixed DNS rejection before transport and streaming overflow tests | The fetcher is not mounted around arbitrary application/provider traffic. Tenant-aware destination authorization, response content/schema validation, data minimization and a deterministic successful live-origin redirect/stream contract remain caller/integration work. |
| Local AI tool dispatch requires an exact allowlist, principal authorization, closed bounded JSON, call budget and audit sink; destructive/financial approvals are one-use and exact-payload-bound. Bounded local RAG/tool trails synchronously persist distinct versioned streams and fail closed on quota, corruption, unsafe targets and competing-writer growth. | Guarded tool registry, policy, tool audit, RAG audit, and shared durable format | Authorization, schema, budget, approval binding, failure, and audit tests, plus restart, tamper, quota, concurrency and real-pipeline durable evidence | Provider-native calling, principal/approver authentication, domain ownership and network egress policy remain application responsibilities. The local trail is single-process; SHA-256 detects corruption but does not authenticate events, and the host owns permissions, rotation, retention, backup and external delivery. |
The Academy production preset normalizes all twelve declared boundaries and fails unless each has one explicit evidenced PASS. | ProductionPreset::academy and the Academy CLI diagnostic | Preset negatives, diagnostic normalization tests, process-level CLI tests, and CLI inventory contract | Evidence is caller-declared and the diagnostic always reports certification: false; it does not inspect a deployment, prove the declaration true, or replace independent review. |
| Live Alipay RSA2 verification and NFS-e homologation/production fail closed instead of returning simulated success. NFS-e local preparation separately enforces pinned XSDs, PKCS#12 XMLDSig, deterministic issuance JSON, bounded signed-authorization and structured-rejection parsing and bounded mTLS construction. | Alipay adapter, NFS-e signer, protocol codec, schema boundary, and NFS-e client | Alipay unsupported-path tests plus fiscal builder, signature verification, protocol binding/tamper/decompression negatives, official-artifact opt-in validation, and environment tests | Offline fixtures and local schema/cryptographic/protocol validity are not legal compliance, certificate trust, tax authorization, settlement, live response evidence, independent review, or provider homologation. |
Release use (v12)
Before a v12 RC or stable release, reviewers had to:
- run the linked tests through the workspace trifecta on the candidate SHA;
- attach multi-OS CI and packaged-distribution evidence to that same SHA;
- confirm the changelog uses the narrow claim wording above;
- keep every remaining boundary visible in the release notes; and
- leave the release at
NO-GOif a claimed control lacks matching evidence.
The v12 threat assumptions and abuse cases are model version TM-12.10 on the
frozen v12 branch; the current threat models are TM-13.0.
The broader implementation/roadmap boundary lives in the
capability ledger.
Security event schema v1
rullst-security::LiveSecurityEvent is the bounded event contract rendered by
Studio/Nexus and accepted by the process-local security store. Its current
schema version is exported as SECURITY_EVENT_SCHEMA_VERSION = 1.
The package also embeds and exports the JSON Schema 2020-12 document as
LIVE_SECURITY_EVENT_V1_JSON_SCHEMA; its packaged source is
security-event-v1.schema.json.
This is an application telemetry envelope, not a remote SIEM transport. The optional local spools provide bounded persistence and one explicit authenticated-journal mode, but the schema itself does not provide delivery, retention, correlation, acknowledgement, retry, dead-letter handling, source identity, or regulatory evidence.
JSON fields
| Field | v1 contract |
|---|---|
schema_version | Integer 1. Legacy JSON without the field deserializes as v1; locally stored events are normalized to v1. |
event_type | Non-empty uppercase ASCII letters, digits, and underscores; maximum 64 bytes. Invalid values become SECURITY_EVENT. |
details | Unstructured human-readable UTF-8 text; maximum 2 KiB and truncated only at a valid character boundary. It must not be parsed as authorization data. |
client_ip | Canonical IPv4/IPv6 string or unknown. Forwarded headers are not implicitly trusted. |
timestamp_str | Absolute RFC 3339 timestamp. Invalid local timestamps are replaced at ingestion. |
verified_hmac | true only when a connected verifier validated an HMAC for that exact event. It does not prove the event’s semantic claim or source identity. push_local_event always forces it to false. |
Example:
{
"schema_version": 1,
"event_type": "RBAC_DENIAL",
"details": "Authenticated principal denied access to the resource",
"client_ip": "192.0.2.4",
"timestamp_str": "2026-08-27T15:30:00.000Z",
"verified_hmac": false
}
Producer rules
New local producers should use LiveSecurityEvent::local(...) and
SecurityStore::push_local_event(...). This path:
- assigns schema v1 and an RFC 3339 timestamp;
- validates/bounds the type and detail text;
- canonicalizes the IP address;
- removes any caller-provided local HMAC claim; and
- stores the event in the bounded 50-entry process-local buffer.
The buffer is deliberately a dashboard snapshot. Loss on restart, eviction at capacity, and absence of a consumer are expected properties, not successful external delivery.
Compatibility rule
Within a stable major line (v12, and v13 from 13.0.0; rullst-security is a
Core crate):
- fields cannot be removed, renamed, or change meaning;
- a new required field or incompatible type requires a new schema version;
- additive optional fields require tolerant consumers and a changelog entry;
- event-type additions are compatible, so consumers need an unknown fallback;
- consumers must not infer trust from
verified_hmacalone; and - all compatibility claims apply to JSON, not field order in serialized text.
The version-one contract has source-controlled serialization, legacy-input,
normalization, size, UTF-8, and CEF-injection tests. DurableSiemSpool can
persist normalized unsigned v1 values in a local single-process file with exact
byte/record quotas, versioned length/digest frames and restart validation.
AuthenticatedSiemSpool is a distinct opt-in file format that authenticates
sequence, named rotation key, predecessor and exact payload before JSON decode;
verified reads set verified_hmac=true only after the complete chain passes.
Release evidence still belongs to the exact release tag SHA.
CEF boundary
format_cef_event is a serializer only. It escapes backslashes, equals signs,
and CR/LF in extension values so event details cannot inject fields or records.
Calling dispatch_siem_alert records a local SIEM-candidate event; it does not
send or acknowledge an external alert.
Durable local spool boundary
DurableSiemSpool serializes one local event per bounded frame, calls
sync_data before returning a receipt, validates every frame on reopen and
rejects truncation, digest mismatch, invalid event JSON and external length
changes. The SHA-256 digest detects corruption; it is not an authentication tag.
The spool deliberately supplies no directory creation, multi-process lock,
rotation, retention, backup, retry, dead-letter handling or remote adapter.
Operators must provide a trusted, permissioned directory and one writer process
per file.
Authenticated local journal boundary
AuthenticatedSiemSpool uses domain-separated HMAC-SHA256 frames and a
SiemKeyRing containing one active write key plus at most seven historical
verification keys. Key material is held in zeroizing storage and omitted from
Debug. Reopen fails closed for an absent/wrong key, forged payload,
non-canonical sequence, broken predecessor chain, removed/reordered interior
frame, symlink target, quota violation or external file-length change.
The chain does not identify whether the local producer’s semantic assertion was true. Removal of a whole valid tail also needs a checkpoint retained in a separately trusted system. The journal does not rotate/compact itself or supply multi-process locking, remote delivery, retry, acknowledgement or dead-letter handling.
A future operational sink should consume the versioned JSON envelope and define redaction, backpressure, authentication, durable spool/retry/dead-letter, delivery acknowledgement, retention, and multi-tenant access separately.
Security Advisory — CORS in Older Scaffolds
Status
- Affected component: applications generated by older versions of
cargo-rullst make:corsor by blueprints that copied a permissive or reflected CORS policy. - Current framework: corrected. New scaffolds use an explicit allowlist and
fail when
CORS_ALLOWED_ORIGINSis missing, empty, contains*, or contains an invalid origin. - Previously generated applications: not modified automatically by a CLI update. Each repository must review the middleware already copied into its source tree.
- Severity: application-dependent. Risk is higher when an untrusted origin can read responses authenticated by a cookie or bearer token.
This is a scaffold migration advisory. It is not a CVE, penetration test, or claim that every final CORS configuration is secure.
Find a potentially affected application
Review the generated CORS middleware and search for these patterns. The snippet below is deliberately incomplete and unsafe; use it only as a search pattern.
AllowOrigin::mirror_request()
.allow_origin(Any)
.allow_credentials(true)
Copying the Origin request header into Access-Control-Allow-Origin
without comparing it against a server-managed allowlist is also unsafe.
Reflected origins or wildcards combined with credentials require immediate
review.
Run an initial search from the application root:
rg -n 'mirror_request|allow_origin\(Any\)|allow_credentials\(true\)|Access-Control-Allow-Origin' src
Review every match. allow_credentials(true) is not inherently vulnerable
when it is combined with a correct closed origin allowlist.
Recommended migration
-
Back up or review the existing middleware and generate the current version on a separate branch with
cargo rullst make:cors. -
Replace reflection or wildcards with exact origins including scheme, host, and port:
CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com -
Keep credentials disabled unless the application genuinely needs cookies or cross-origin authentication. If required, enable them only after validating the exact allowlist.
-
Restrict methods and headers to the API’s actual contract. The current scaffold provides a baseline; it cannot infer every application endpoint.
-
Test at least one allowed origin and negative cases covering a similar host, unauthorized subdomain, different port,
null, and noOriginheader.
Example manual check: an unauthorized origin must not receive an
Access-Control-Allow-Origin response header.
curl -i -X OPTIONS https://api.example.com/resource \
-H 'Origin: https://evil.example' \
-H 'Access-Control-Request-Method: POST'
Completion criteria
The migration is complete when:
- no origin is reflected or accepted by wildcard;
- the process fails explicitly when required configuration is missing or invalid;
- credentials are accepted only for exact authorized origins;
- negative tests prove that deceptively similar origins receive no authorization;
- caches and proxies receive
Vary: Originwhen the response depends on the request origin.
The current template is
cargo-rullst/src/generators/cors_middleware.rs.template.
Security advisory exceptions
Rullst does not treat an ignored scanner finding as remediated. Any temporary exception must have a narrow scope, a compensating control, an owner, and an expiry date. CI must fail for every advisory that is not actively governed here.
Last reviewed: 2026-10-22.
There are no active advisory exceptions in the current audited dependency
graph (deny.toml and the audit workflows ignore no advisory).
Remediation history
- On 2026-10-22, the time-bounded exception for
RUSTSEC-2026-0367,RUSTSEC-2026-0368andRUSTSEC-2026-0369(zip6.0.0 as a build dependency oflibduckdb-sys, approved on 2026-10-17 until 2026-10-31) was removed:duckdb/libduckdb-sys1.10506.1 build withzip9, andrullst-ormnow requires it. The same change movedrustls-webpkito 0.103.16 andwebpki-roots/webpki-root-certsto 1.0.10 (RUSTSEC-2026-0381to-0383) andammoniato 4.2.4 (RUSTSEC-2026-0373). - On 2026-09-08,
RUSTSEC-2023-0071was removed from the production and fuzz dependency graphs by movingjsonwebtokenfrom itsrust_cryptobackend toaws_lc_rs. The CLI scaffolds the same backend for newly generated JWT middleware. Auth and Connect’s RS256/OIDC tests remain enabled, and Cargo Audit runs without an exception. - On 2026-08-26,
RUSTSEC-2026-0173(proc-macro-error2) andRUSTSEC-2024-0436(pastethrough Leptos) were removed from the resolved dependency graph. Their workflow ignores were removed in the same change.
These are historical remediations, not active exceptions.
Advisory response SLA
The clock starts when a maintainer receives a credible private report or an automated advisory first appears on a protected branch. Severity uses the highest credible impact while triage is incomplete.
| Severity | Acknowledge and assign owner | Mitigate or release target | Maximum temporary exception |
|---|---|---|---|
| Critical | 1 business day | 72 hours | 14 days |
| High | 2 business days | 7 calendar days | 30 days |
| Medium | 5 business days | 30 calendar days | 90 days |
| Low / unmaintained without known vulnerability | 10 business days | 90 calendar days | 180 days |
If the release target cannot be met, maintainers must disable or isolate the affected capability, yank an affected prerelease when appropriate, or create a time-bounded exception below. Critical/high exceptions require release-owner approval and a documented reason that disabling the capability creates greater risk. An exception is never evidence that the vulnerability is fixed.
Review procedure
At or before expiry, the owner must either remove the dependency, upgrade to a
fixed dependency graph, or record a new review with fresh evidence and a new
short deadline. The reviewer must inspect the dependency path with Cargo Tree
and run the repository’s supply-chain.yml workflow (Cargo Deny, Cargo Audit
and the exception governance check).
Patched rustls-webpki, event-listener, and lru findings and the removed
HTTP/2 dependency from Actix’s disabled default features are deliberately not
ignored. This makes a regression in any of those versions block CI again.
Rullst CI/CD and Verification Contract
This document describes what the repository’s automation currently executes. It is not evidence that a workflow has passed for a particular commit. A green claim must always point to the GitHub Actions run, commit SHA, logs, and produced artifacts.
Last source-level review: 2026-09-20 UTC.
Status language
| Status | Meaning |
|---|---|
| Blocking | A failing command fails that workflow run. Branch protection still determines whether the check is required for merging. |
| Automated evidence | The workflow runs automatically, but part of its result is external, uploaded, or deliberately non-blocking. |
| Informational | The workflow is explicitly advisory and must not be described as a release gate. A manual trigger alone does not make a strict candidate check informational. |
| Roadmap | The idea is preserved, but this repository does not yet provide reproducible evidence for it. |
The distinction matters: Kani, Miri, mutation testing, or a scanner can be very valuable without proving that the entire framework is panic-free, race-free, memory-safe, or compliant with a regulation.
Branch transition
On 21 September 2026, v12 was preserved from stable source 184bc1f7.
The hosted main and v12 protections were read back with the same 43 required
GitHub Actions checks as v13, an up-to-date base, administrator enforcement,
conversation resolution and disabled force pushes/deletion. This is a settings
observation, not a successful test receipt. Promotion of v13 source to main
requires a normally admitted PR; existing tags, packages and running feature
campaigns are retained. v13 remains transitional while that admission runs.
The default-branch Dependabot configuration targets Cargo (the root and the
eleven fuzz workspaces) and Actions on main only; v12 receives no updates
after its end of life on 16 October 2026. Minor/patch Cargo changes share a PR,
the OpenTelemetry and utoipa families each have their own, and Actions share one
tools PR. Updates still require compatibility/MSRV and protected CI; group
membership is not approval. Old Dependabot PRs are closed after equivalent
updates have entered main, or with a comment when its policy excludes them.
Mainline execution model
The development dashboard and its automatic status badges follow main. The
declared automatic release workflows accept pushes and pull requests targeting
main, stable maintenance v12, and transitional integration v13,
and expose workflow_dispatch where a safe rerun is useful. Superseded runs of
these workflows are cancelled per workflow and ref so rapid development does
not spend runner capacity proving an obsolete commit.
Release policy schema 4 binds v12 to v12 and v13 to main. Historical
schemas 2 and 3 retain their original bindings. Before
building release artifacts, the tag, every inventory package version and the
current protected branch head must agree. Exact-source workflow admission and
the crates.io deployment approval remain mandatory. Fuzz receipts are limited
to the candidate’s release line, including the source policy of reused runs.
ci.yml deliberately treats the expensive operating-system matrix differently.
Format and Clippy continue to give feedback on draft pull requests. A ready pull
request to main runs the fast Linux gate: the workspace and cli-standard
shards, MSRV, strict database features, Redis/S3 contracts, generated access,
facade composition and AI evals. Line coverage, the SemVer registry comparison
and CodeQL skip pull requests to main; the SemVer workflow’s public-API
snapshot job runs on ready pull requests to main. The resulting main push runs every Linux
shard, public feature boundaries, the threat-model minimum, coverage, SemVer and
CodeQL. A nightly scheduled run (05:30 UTC) executes the complete
Linux/macOS/Windows matrix on the default branch. A maintainer can add the
full-matrix label to a pull request to main to run that complete
Linux/macOS/Windows test matrix (all seven shards on each system) before merge:
the labeled event starts it, and every later push re-runs it while the label
stays. The label changes only the Run Tests matrix; feature boundaries, the
threat-model minimum, the scorecard and the release-only packaging jobs keep
their pull-request guards. Every pull-request event, labeled included, uses
the pull request’s concurrency group, so the newest run replaces the older one
and still reports every required check (.github/test-ci-matrix.py evaluates
the workflow’s expressions to check this). Adding any other label re-runs the
same Linux gate rather than skipping it, because a skipped job would report a
passing required check. Ready pull requests to the
maintained v12 line and transitional v13 keep the complete matrix, blocking
line coverage, SemVer fan-out and CodeQL before merge. Any matrix can be
requested manually. Each operating system
executes seven parallel shards: the non-CLI workspace (which includes the Blog,
ERP, LMS, Portfolio and WebGPU examples), ordinary CLI targets, three public-profile groups
and two generated-starter groups.
The basic/relational/polyglot and foundation/product partitions retain every original
case while bounding the longest Windows and macOS jobs. No test is omitted;
this changes wall-clock scheduling rather than the assertions being executed.
Hosted CI permits two nested compiler jobs for the otherwise serial generated
profile/blueprint builds; constrained local runs retain their one-job default.
Each CLI
shard fetches the locked registry inventory before its generated applications
prove that they compile without network access. After a
reviewed commit is merged, the automatic main or v13 push runs Linux only;
for main, macOS and Windows evidence comes from the nightly run. Release
candidates must use the manual full matrix when no successful full-matrix run
points to the exact candidate tree.
The SHA-bound quality scorecard is generated only by a ready pull request to a
maintained line, the nightly run or a manual full-matrix run. It is deliberately
skipped on Linux-only runs (automatic branch pushes and pull requests to
main), because they cannot honestly award cross-platform verification credit. Run ci.yml manually on a final release-branch candidate to
produce the exact-SHA release scorecard. Manual diagnostic runs may select one
operating system and one test shard; those deliberately do not produce a
full-matrix scorecard and do not replace final-candidate evidence.
The manual cli-updates diagnostic selects discovery/cache/artifact tests,
isolated project preparation/verification and legacy upgrade process fixtures.
It does not replace the complete cli-standard shard in release matrices.
Manual CLI-only shards also skip unrelated ORM/Redis/feature/threat/eval/facade
and MSRV jobs, keeping correction runs bounded. Manual workspace and all
selections, automatic runs and ready PRs retain those jobs. Strict workspace
Clippy/format still runs for every diagnostic; release admission continues to
require every job from the full all/all matrix.
The manual all/all matrix additionally packages the 20 release-inventory
crates, including rullst-labs, audits their contents and
uses the release pipeline’s archive-only consumer and isolated CLI
installation/blueprint checks. Packaged distribution and installed CLI is a required exact-SHA release-admission job. It runs without
registry publication credentials and never uploads to crates.io. Automatic
development runs and other diagnostic subsets deliberately skip this expensive
job. The explicit platform=all, shard=packaged-distribution diagnostic runs
this same archive/installed-CLI check and workspace Clippy/format, without
repeating the OS test matrix or native artifact builds. It emits no full-matrix
scorecard and cannot satisfy complete release admission; the final all/all
and tag pipelines retain their existing package verification.
Generated VM deployment (docker) and (podman) run
.github/check-vm-deploy.py (about ten minutes each): they install the CLI
from the packaged archives and, for the Blank and SaaS starters, run
cargo rullst deploy, copy the project as the
VM guide does, check .env.production with
deploy:doctor, build the image with the generated Dockerfile and start the
generated compose.yaml and Caddyfile. They check /health over HTTPS
through Caddy (verified against Caddy’s local CA for localhost), the
HTTP-to-HTTPS redirect, that the app port is not published, the read-only root
filesystem, capabilities and no-new-privileges of both containers (read from
/proc), Caddy’s trusted-proxy address, db:migrate, a database-backed page
(SaaS: registration and sign-in with a Secure session cookie), that the SQLite
database stays in its volume across docker compose restart (Podman: down
and up -d, because podman-compose’s restart can leave Caddy stopped), the
guide’s backup command and that no container logged a panic. The release
crates enter the build context as packaged archives through
[patch.crates-io]; the generated deployment files run unmodified. Docker uses
the runner’s daemon; Podman runs rootless through podman compose with
podman-compose and the net.ipv4.ip_unprivileged_port_start=80 setting the
guide documents. Full manual runs, the nightly run and the manual
vm-deployment diagnostic run both jobs; a ready pull request runs them only
when it changes cargo-rullst/src/generators/deploy.rs,
generators/project/docker.rs, blueprints/deploy.rs or the check (otherwise
the job succeeds without building), and branch pushes skip them, so the push
inventory that site-only admission reads is unchanged. They are not listed in
the release policy’s required jobs, but a failure fails the full manual run
that admission requires. Real DNS, Let’s Encrypt, provider accounts, Arm
images and reboots are not covered.
On Linux, the cli-profiles-relational shard then runs
.github/check-auth-starter-sql.py with the CLI it built. For PostgreSQL 15,
MySQL 8.4 and MariaDB 11.4, each in a disposable container bound to loopback,
it generates a Blank starter, points its .env at the server, runs
cargo rullst db:migrate, inserts two rows (a name; default timestamps), runs
cargo rullst auth, mounts the sign-up routes as Tutorial 11 describes and runs
db:migrate again. SQL against the server then checks the six added columns
(types, nullability and defaults), the unique users_email_unique index and
that it rejects a duplicate, the user-<id>@no-email.invalid placeholders
without a password, that the starter’s columns, indexes and rows are otherwise
unchanged, and the batch’s migration records and password_reset_tokens
table. The built application must sign up an account over HTTP (/dashboard
opens, the row holds an Argon2id hash). db:rollback must restore the
starter’s columns, indexes, tables and migration records exactly and keep
every row, and a second db:migrate must apply the batch again. The generated
builds reuse the shard’s target directory and the workspace Cargo.lock
offline. The shard runs on main pushes, nightly, with the full-matrix
label and in manual runs (the manual cli-profiles-relational selection with
ubuntu-latest runs it alone); a pull request to main without the label does
not. The API and SaaS starters, other server versions and existing deployments’
data are not covered.
First-hour documentation runs .github/check-first-hour.py: it follows the
path the documentation gives a newcomer, running the shell blocks and applying
the code blocks marked <!-- first-hour: … --> in the README quickstart, the
getting-started page, tutorials
01,
02,
03,
05 and
11 (the last four in one
shop project) and the REST API quickstart,
in reading order. The marker grammar is in .github/first_hour_docs.py; what
each page says happens (files written, /health, the welcome page, the API
starter’s bearer-protected POST /api/messages, migrations, rollback and
seeding in SQLite, sign-up and sign-in after cargo rullst auth) is checked by
.github/first_hour_checks.py, keyed by page and step. The CLI is installed
from the packaged archives, and a [patch.crates-io] in the scratch
directory’s .cargo/config.toml resolves the unpublished release crates to
them, so every project is built as written; only cargo add rullst@=<version>
is replaced by the line it writes while that version is not on crates.io.
cargo rullst new without --default answers the wizard with Enter in a
pseudo-terminal, and cargo run/cargo rullst dev run in the background until
the next block stops them with SIGINT, as Ctrl+C would. A failure is annotated
on the page’s line. The trigger rules are the deployment job’s: full manual
runs, the nightly run and the first-hour diagnostic run it; a ready pull
request runs it only when it changes a marked page, cargo-rullst/src/ or the
check, and branch pushes skip it. It is not a required release job.
.github/test-first-hour.py (in the Documentation workflow) validates the
markers, the documented install versions and the edits offline on every push.
Pages outside the first hour, external accounts (Turso, Stripe, mail
providers), PostgreSQL/MySQL servers and the browser itself are not covered.
Manual all-platform all and cli-standard selections also call the native CLI
artifact builder for Linux x64, Windows x64, macOS ARM64 and macOS x64. The
committed target inventory selects explicit runner labels. Both executable entry
points must run and report the candidate version before bounded files, digests
and source/platform metadata are retained. These ordinary CI artifacts have no
release tag and no installation or publisher-verification authority. Exact
full-candidate admission requires all four native jobs; a diagnostic CLI shard
still does not replace the full matrix.
The tag-only release also calls this builder after protected release-branch admission. The separate attestation job verifies downloaded checksums and includes native executables/manifests in build provenance without executing source or binaries. The GitHub release job adds those assets only after attestation and registry publication succeed. This prepares distribution, not an installer; no new installation command or platform recovery claim is implied.
The existing Linux workspace shard also requires
RULLST_UI_BROWSER_TESTS=1 with Node 24 and Chromium. It feeds HTML from the
real Nexus renderer and the Portfolio example’s view (examples/portfolio) into
the bounded .github/mobile-ui-browser-smoke.mjs contract: drawer
dismissal/focus/no-JS behavior, responsive layout at 320–1440px, long content
and reduced motion. Missing browser prerequisites fail that shard. Other platforms still run the render assertions and the complete
existing project matrix; they do not claim Chromium evidence. Local runs opt in
with the same flag and an absolute RULLST_UI_BROWSER_SCRIPT path. CDN resources
are blocked in this UI fixture. The Nexus run serves the shell with the default
production CSP and its real same-origin assets, and fails on a CSP violation;
this is not live-provider, WebKit/Firefox,
hardware-device, or WCAG certification. No extra Rust compilation matrix is added.
GitHub executes schedule events from the repository’s default branch, so
scheduled checks follow v13 development on main. Stable v12 receives
its own push/PR checks and explicit manual campaigns; a scheduled main run
is not stable-line evidence.
Tag publication remains deliberately unavailable through a manual button.
Manual and periodic execution map
Every verification workflow except the PR-context-only pr-security-evidence.yml and mutants-pr.yml,
reusable cli-artifacts.yml and tag-only release.yml can be started from Actions → select workflow
→ Run workflow. A manual run checks the selected branch’s current SHA; record
that SHA and the run URL before treating it as release evidence. The release
workflow intentionally has no button because its publication authority begins
only with an exact version tag.
Fuzz target jobs have one bounded exception to repeating expensive work on an unchanged input surface: verified evidence reuse. The candidate still requires its own successful fuzz workflow; the admission checker independently verifies every reused original job and source digest. No other workflow gains cross-commit credit from this exception.
The workflows below are started manually for release evidence. kani.yml
and miri.yml also run weekly on main (next table), but release admission
counts only a dispatched run:
| Workflow | Evidence | RC interpretation |
|---|---|---|
dast-zap.yml | OWASP ZAP baseline against a release blog showcase, a fresh generated REST API, a fresh generated SaaS starter (public pages, then its signed-in pages with a disposable account’s session) and the LMS example (examples/lms, release-built from the workspace) | REST/SaaS/LMS warnings and failures block unless an exact rule ID is versioned as INFO with a local explanation in .zap/; those configs are passed explicitly to the pinned scanner and unlisted warnings remain live. The showcase is informational because it deliberately uses third-party presentation assets; reports and application logs are retained. This remains representative, not universal deployment coverage. |
fuzzing.yml | All 42 declared libFuzzer targets from the validated shared inventory | Required release evidence: release mode validates all 42 targets and independently verifies reusable input-equivalent evidence; packages with remaining targets run locked compilation preflights and new 5.5-hour campaigns; target-specific corpora are restored and saved, while failure reproducers are retained. Dependency-lock drift fails preflight and campaign jobs. The proc-macro parser uses strict processes of at most 30 minutes sharing one corpus, which bounds sanitizer RSS without weakening the total budget. Original reused results must be at most seven days old, come from an ancestor on the same release line, and prove the complete budget. The final candidate still runs the evidence boundary and publication independently recomputes all 42 results. Reuse policy. A strict five-minute single-target diagnostic accelerates correction feedback but is explicitly ineligible as release evidence. This is bounded evidence, not proof for every input. |
kani.yml | Twenty named bounded formal harnesses in ten supported runtime/library packages | Required release evidence for the declared harnesses: every proof has an isolated strict matrix job. Rullst itself stays on stable Rust 1.99.0 with a Rust 1.96 MSRV; only the separately built Kani verifier uses its pinned nightly-2026-08-01 compiler (rustc 1.99.0-nightly) because the latest stable Kani bundle’s Rust 1.93 compiler cannot compile the framework. The proc-macro-only rullst-macros target remains unsupported by Kani and is covered by compile-pass/fail and generated-project evidence instead. |
miri.yml | Randomized-layout Miri execution over 15 named pure-Rust/default-feature scopes | Required release evidence for the declared scopes: every selected scope is strict. This nightly-only interpreter uses pinned nightly-2026-08-21 (rustc 1.100.0-nightly); it does not change the project’s stable toolchain or MSRV. Native FFI, OS syscall, network/provider, umbrella re-export, and example-application boundaries are excluded explicitly rather than emitted as tolerated errors. |
mutants.yml | A source-bound discovered inventory, eighty lossless shards with at most four running concurrently, their artifacts and a strict aggregate | Informational: a cheap all-feature --list --json preflight records the selected source’s complete unique inventory before runners start; every shard then uses that release surface, and aggregation requires every reviewed candidate to receive exactly one classification before reporting the conservative caught percentage. A targeted mode retests one validated production Rust file after a correction, with explicit per-mutant test/build timeouts and an optional library-only test scope; it does not replace the complete campaign. Every shard has a pinned Redis service, so the RULLST_TEST_REDIS_URL contracts run for each mutant. A contract mode runs one owned-server wrapper’s mutation mode, which mutates only that wrapper’s PostgreSQL or Redis adapter files and runs only its contract tests against the server it owns; it checks its own outcome count instead of the workspace inventory and aggregate. Recovery modes accept only the repository-reviewed campaign policy, bind the source/run/branch/tool/inventory digest, bisect explicitly authorized failed fragments, reuse immutable successful artifacts and emit a content-addressed aggregate. Missed/time-out exit codes remain findings, while a broken baseline, incomplete artifact set, preflight/classification mismatch, invalid invocation or cargo-mutants internal failure fails the workflow. The v12 campaign completed 14,391/14,391 classifications in run 34761010296; “pass” does not mean every mutant was killed. |
The v13 candidate also provides verus.yml as a manual-only pilot. Its source
linkage/proof/negative-control checks are strict when invoked, while the pilot
remains outside release admission. A new workflow must first be available in
GitHub’s default-branch workflow registry before its manual button/dispatch can
be assumed available; prepared YAML is not hosted evidence.
These workflows are periodic and manually runnable:
| Cadence | Workflows | Mode |
|---|---|---|
| Daily | ci.yml, sanitizers.yml, supply-chain.yml | Supply Chain (Cargo Audit of the production and fuzz locks, Cargo Deny and the advisory-exception check) is blocking; the nightly ci.yml run executes the complete Linux/macOS/Windows matrix on main; TSan/ASan are blocking when executed. |
| Weekly | bench.yml, codeql.yml, coverage.yml, crypto-containment.yml, documentation.yml, kani.yml, miri.yml, proptest.yml, scorecards.yml, trufflehog.yml, udeps.yml | The inventory below identifies which results are blocking, automated evidence, or informational. Miri (Wednesday 01:17 UTC) and Kani (Thursday 01:47 UTC) run their full strict matrices so that a regression shows up within a week rather than at release admission. |
All remaining test/build workflows run on the documented push, pull-request or
path filters and also expose a manual rerun. For an RC or stable checkpoint,
first use the automatic mainline suite, then run the exact-SHA manual admission
set: full-platform ci.yml, release-mode fuzzing.yml, dast-zap.yml,
kani.yml, miri.yml, sanitizers.yml and proptest.yml. The tag workflow checks those receipts through
.github/release-required-workflows.json; the fuzz receipt is accepted only
when all 42 named target jobs and its evidence boundary succeeded. A scheduled
run of ci.yml, sanitizers.yml, kani.yml, miri.yml or proptest.yml is
never a receipt, even on the candidate commit: admission accepts only
workflow_dispatch runs, and each scheduled run has its own concurrency group
(-nightly or -weekly suffix), so it cannot cancel, or be cancelled by, the
dispatched admission run on main. Physical
devices, store approval, live provider accounts, external security review and
human release approval remain outside GitHub Actions.
Required local and release baseline
The contributor baseline from AGENTS.md is:
cargo test --workspace --all-features
cargo clippy --workspace --all-features -- -D warnings
cargo fmt --all
The main CI uses the stricter all-target Clippy form and checks formatting without modifying files:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
Rust CI disables Cargo incremental compilation and uses the pinned sccache
Action and binary to store content-addressed compiler outputs in GitHub Actions
cache. The current cc build dependency also honors the same Rust compiler
wrapper, so compatible bundled DuckDB C++ objects can be reused. Same-repository
pull requests can populate only their GitHub-isolated refs/pull/.../merge
cache scope, making a failed-job rerun useful without modifying the trusted
default-branch namespace. Fork pull requests remain read-only. Pushes and
explicit manual runs on main populate entries reusable by later pull
requests. Cache contents never substitute for a test result.
The tag-only verifier uses the same namespace strictly read-only, so it can
reuse an exact trusted compiler output but cannot alter the cache while
creating release artifacts; every release command and assertion still runs.
The setup action’s job-scoped Cargo archive is disabled in these compiler-cache
jobs, so neither the raw workspace target tree nor duplicate registry bundles
compete with compiler objects. CLI integration tests create and remove nested
Cargo targets, and archiving whole mutable trees previously produced false
missing-directory annotations, duplicated roughly 9.56 GiB across twenty
active main caches, and caused eviction churn at GitHub’s default 10 GiB
repository limit. Cargo may redownload registry sources on a fresh runner; this
small network cost is preferable to storing the same registry/target archive
under many job-specific keys. A first run on a new cache namespace is still a
cold build; evaluate acceleration using the reported cache hit ratio and a
later compatible run, never by weakening or omitting assertions.
The same content-addressed approach accelerates LLVM coverage, benchmark,
mutation/fuzz compilation and the scheduled release-mode regression suite.
Benchmarks remain
sequential on one runner so comparisons do not mix host variance. Coverage
deliberately remains one report job because splitting it without a reviewed
profile-data merge could change the repository percentage. Its second
default-feature pass is limited to ORM, Studio and the public facade: those are
the packages with default-SQLite tests excluded by the mutually exclusive
all-feature graph, so unrelated workspace tests are not repeated. The release-mode
workspace is safe to split because every shard
returns an ordinary test result, while the two source locations that actually
use proptest! still receive their configured 10,000-case runs. SemVer checks
fan out from the machine-readable release order and validate one published API
per job, so adding or removing a release package cannot silently drift from the
matrix.
The Public API snapshot job of semver.yml runs .github/public-api.py --check (after its unit tests, .github/test-public-api.py). It builds the
rustdoc JSON of each library package in the release order, except the
cargo-rullst CLI implementation, with the features and rustdoc arguments of
its [package.metadata.docs.rs] table, on the pinned nightly-2026-08-21.
It renders every documented item, signature, field, variant, implementation
and auto trait into .github/public-api/<package>.txt and fails when the result
differs from the committed file. Items hidden with #[doc(hidden)] and blanket
implementations are not listed. A pull request that changes a public API
commits the regenerated listing, so the change is reviewed as a diff. The job
runs on ready pull requests to main, on pushes and on manual runs; a manual
run with public_api_only skips the registry comparison. It is not a required
status check, but a failure fails the semver.yml push run that release
admission requires.
The all-feature coverage pass uses pinned cargo-nextest to schedule the same
discovered unit and integration tests concurrently. It performs no retry and a
flaky retry could not be normalized into success. A repository profile fixes
four global slots and permits at most two integration tests that launch nested
generated-application Cargo builds, preventing compiler fan-out from trading
latency for memory or disk exhaustion. The exact per-test JUnit result and
duration record is retained for 30 days. This runner is a coverage scheduler,
not a replacement for Rust CI: the required multi-platform shards continue to
execute the complete inventory with ordinary cargo test, preserving both
traditional libtest process semantics and the materialized-project gates.
CodeQL runs separate Rust and JavaScript/TypeScript analysis jobs. The Rust
job’s Cargo target cache is disabled so the
extractor observes compilation for the exact SHA instead of inheriting a fresh
artifact from another run; the analysis database itself is not interchangeable
with ordinary test shards. Rust CodeQL’s faster buildless mode is intentionally
not used because manual compilation gives the extractor the stronger generated
code boundary needed by this release. The JavaScript job uses buildless analysis
and retains the /language:javascript category to compare against existing
mainline results without leaving that language unexamined.
ci.yml also compiles and exercises each ORM strict database feature in
isolation (PostgreSQL, MySQL, and SQLite), exercises the runtime-only Core and
all 70 public umbrella features in isolated additive graphs with automatic
manifest-drift detection, runs the portable database matrix on Linux, and
tests the complete all-feature workspace in seven parallel shards on Linux,
macOS, and Windows. Feature-boundary rows and threat-model negative tests also
fan out into four deterministic strict shards each; their matrix job remains a
single blocking dependency for the quality scorecard. Each threat-model shard
primes the reviewed lockfile before its deliberately offline generated-project
checks, so it does not inherit a hidden source-cache dependency from another
job. The umbrella’s
cfg(doctest) aggregation reads all 48 public tutorial files directly, so that
same command discovers the versioned Rust blocks, compiles or executes complete
examples, and records explicitly contextual fragments as ignored instead of pretending
they are standalone programs. Its pinned live Redis job also proves that
scheduled Core jobs are not claimed early, that Core cache inspection returns
bounded metadata without values, plus ORM
cache hit/TTL/recovery, tenant/table invalidation, rollback preservation,
process-local post-commit observers and Scout commit ordering. A separate
SQLite outbox contract runs on all three operating systems and covers
atomicity, conflicting idempotency keys, claim races, lease expiry,
retry and dead-letter; the relational matrix repeats the core outbox lifecycle
against PostgreSQL, MySQL, MariaDB and strict SQLite. A dedicated job checks
the declared MSRV, Rust 1.96.0. The Linux provider matrix also runs the
feature-gated Scout adapter against a digest-pinned Meilisearch image; Algolia
and Elasticsearch use bounded local protocol fixtures because no hosted
provider account is part of CI. The same matrix runs typed, parameterized L2
and cosine queries against a digest-pinned PostgreSQL + pgvector image. It also
runs Nexus’s default Any/SQLite HTTP contract explicitly, because the global
all-feature graph intentionally selects a strict database profile and excludes
that materialized tenant/audit target. Coverage separately merges the default
workspace pass, so those routes contribute real executed-line evidence.
After a ready-PR or manual full-matrix Rust CI run finishes, an observational job emits a SHA-bound per-crate quality scorecard into the workflow summary and a 90-day artifact. The score combines versioned expert-audit ceilings with the actual gate results; a failed/skipped/cancelled gate can remove the dimensions it was meant to prove, while a green gate cannot inflate a crate beyond its audited ceiling. This is engineering-evidence reporting, not capability completion or certification. See the scorecard methodology.
Rows with no feature selected compile every package target. Feature-selected rows compile the isolated library graph; feature-enabled tests, examples, and benchmarks remain covered by the workspace and specialist jobs. This avoids pulling unrelated development dependencies into every boundary while retaining real integration coverage.
The tag-only packaged-distribution gate reads the complete feature set from the
extracted rullst package manifest and compiles that crates-only consumer with
defaults disabled and every public feature enabled. A partial hand-maintained
feature allowlist therefore cannot make a monorepo-only integration appear
release-ready.
Recommended branch-protection profiles
main (v13 development) requires the fast gate of 19 checks that every
ready pull request emits: Code Quality & Format; Run Tests (ubuntu-latest / workspace) and
(ubuntu-latest / cli-standard); Check MSRV (Rust 1.96.0); ORM strict-sqlite,
strict-postgres and strict-mysql; Redis live contracts; Generated release access
boundaries; Facade shared-local recovery composition; Versioned deterministic AI
evals; Build book and validate local links; Validate workflow syntax and
expressions; Enforce Zero Panics (published runtime targets); Enforce production
unsafe boundary; Check Dependencies (Licenses, Vulnerabilities); Scan for leaked
secrets; Enforce reviewed internal dependency graph; and Typo Check. It does not
require an up-to-date base: the full main push run and the nightly matrix
detect interactions between independently green pull requests, and each
additional rebase would otherwise repeat the whole gate. Cargo Deny’s job in
supply-chain.yml is the required vulnerability gate. Until 13.0.0-alpha.4 the
two Cargo Audit workflows both emitted an ambiguous audit context; their jobs
now have distinct names (Cargo Audit (production and fuzz locks), Advisory exception governance), so either could be required without a collision. The
macOS/Windows and CLI-profile shards, public feature boundaries, threat-model
minimum, coverage, SemVer and CodeQL run after merge, nightly or weekly. They
remain release-admission requirements.
v12 (frozen; end of life on 16 October 2026) keeps the complete profile
of 46 checks with an up-to-date base: every Linux/macOS/Windows test shard,
public feature boundaries, the threat-model minimum, CodeQL, documentation and
workflow lint.
Do not configure a path-filtered, scheduled, manual, deployment, or tag-only
workflow as a universal required check: an intentionally skipped workflow may
never create the check context. In particular, IoT and Vault Crypto Containment is
blocking when relevant paths change.
Pages, benchmarks, fuzzing, sanitizers,
Kani, Miri, mutation testing, udeps, ZAP, Scorecard, and release provenance
belong to deeper evidence or release policy. GitHub repository rulesets remain
the enforcement source; this document records the recommended profile. On
29 October 2026 the hosted rulesets matched it: the
development-branch ruleset
on main and the
maintained-branch ruleset
on v12 (see GOVERNANCE.md).
Observed v13 protection
On 20 September 2026 UTC, the hosted v13 branch protection was enabled and
read back from the GitHub API. It requires 43 existing GitHub Actions contexts:
the 25 Linux runtime jobs, all sixteen corresponding macOS/Windows test shards,
documentation and workflow lint. Contexts are bound to GitHub Actions, the base
must be current, a pull request and resolved conversations are required, and
administrators are included. Force pushes and branch deletion are disabled.
No additional human approving review is required. These are hosted settings at
that observation, not a guarantee that an administrator cannot change them.
This initial merge profile does not replace the release policy’s 28 complete
workflow receipts, manual native/security jobs, package inspection or protected
crates.io deployment approval. The additional automatic security workflows
are enabled on v13 by this preparation change; the profile above names only
checks already emitted by the integration candidate. The v13 branch was later
retired into main, and the release policy now requires 25 workflow receipts
(.github/release-required-workflows.json).
Phase 4 release-engineering status
| Release-engineering goal | Current status | Assessment |
|---|---|---|
| Trifecta with all features | Implemented | CI and tag release both run format, all-target/all-feature Clippy, and all-feature tests. |
| Strict DB features in isolation | Implemented in workflow | strict-postgres, strict-mysql, and strict-sqlite compile independently and each runs a backend-specific CRUD test with only the selected strict feature enabled. |
| Honest blocking/informational labels | Implemented | Unsafe and Wasm checks are blocking in continuous CI; the declared Kani, Miri and fuzzing scopes are strict release gates; mutation testing and udeps explicitly remain informational. |
| Cover every fuzz target | Implemented in workflow | .github/fuzz-targets.json is the inventory for the manual campaign and the workflow-lint and Rust CI lock checks. A blocking validator compares it with all eleven fuzz manifests and their 42 source files. This records configuration, not a successful six-hour run. |
| Package all crates before publishing | Implemented in workflow | The tag-only release validates versions, packages all publishable workspace crates, hashes and attests the archives, then publishes in dependency order. |
| Unified evidence bundle per tag | Implemented in workflow | The tag-scoped bundle contains Cargo.lock, Cargo metadata, CycloneDX 1.5 SBOM, Cargo Audit JSON, deny.toml, bounded compliance evidence, governed advisory exceptions, commit/tag context, and checksums. The bundle and .crate archives are included in build-provenance attestation. |
| Align manifest, changelog, tag, registry, and notes | Implemented in workflow | Preflight validates manifests and tag, extracts the exact dated changelog section into the GitHub release, and verifies registry checksums after publication. |
Important evidence boundaries
Zero-panics and unsafe Rust
zero-panics.yml denies Clippy’s unwrap, expect, panic, todo, and unimplemented
lints for published runtime libraries, procedural-macro engines, CLI production
targets, generated runtime templates, and the Wasm Core path. Tests are excluded
where assertion panics are test semantics.
unsafe-policy.yml compiles production libraries and binaries with
-Dunsafe-code. The exact reviewed source allowlist contains the Radar OS probe,
dynamic-library loader, the CLI’s Windows cache/installation owner/DACL boundary,
Windows project access-policy preservation and macOS extended-ACL inspection.
Both inner and outer unsafe-lint attributes enter the source inventory.
The macOS module borrows an open descriptor,
inspects the first ACL entry and frees the returned allocation; it never edits
an ACL. Its native regression adds an ACL and verifies that replacement rejects
without discarding it. Windows descriptors are installed atomically and
validated through owned handles. Each unsafe call documents pointer/handle
ownership and lifetime; the workflow fails if the source allowlist changes.
Windows ACL counts come from GetAclInformation into owned output structures;
ACE pointers must be non-null before creating bounded borrowed views. SID lengths
are checked before OS validation and no borrowed pointer outlives its descriptor.
This is an enforced boundary, not a claim that all dependencies contain no
unsafe Rust.
Coverage
coverage.yml runs LLVM coverage over workspace all-features and default
profiles plus the live database matrix, then uploads LCOV to Codecov using
GitHub OIDC rather than a long-lived upload secret. It also retains exact JSON
and text line summaries for 30 days so a passing upload cannot be confused
with the coverage percentage. The all-feature pass uses pinned cargo-nextest
only to run the same discovered tests concurrently, with zero retries, bounded
nested-Cargo concurrency and a retained JUnit execution record. Rust CI still
runs ordinary cargo test across Linux, macOS and Windows. The default-profile pass explicitly includes
ORM, Studio, Nexus and the umbrella facade so their real SQLite contracts are
not hidden by mutually exclusive all-feature database profiles. Before upload,
the workflow independently rejects an LLVM summary below 90% for either the
whole repository or the governed framework-library paths. codecov.yml also
requires at least 90%, with zero tolerance, for those views and changed lines;
failure to upload LCOV also fails the workflow. The report
filters examples, benchmarks, auxiliary test support, and separate test files.
CLI and proc-macro code therefore remains part of the blocking repository
aggregate and is additionally visible as informational components. Their
stronger semantic evidence still comes from materialized scaffolds and
compile-pass/compile-fail contracts. The README exposes both the public overall
badge and the separate framework_libraries badge rather than substituting the
higher component result for the repository total.
Formal, dynamic, and stress analysis
- Kani and Miri are bounded evidence scoped to the harnesses/packages that
actually execute. Both run weekly on
mainso that a regression appears between releases; release admission still requires a dispatched run on the exact candidate commit. Rullst itself remains pinned to stable Rust 1.99.1 and keeps Rust 1.96 as its declared MSRV. Kani builds reviewed upstream revision8fcd6d90ed07b559e553ca8a92b95f2db69b2c78with the verifier’s own pinnednightly-2026-08-01compiler (rustc 1.99.0-nightly) into a bundle and installer, then treats proof failures in twenty isolated harness jobs across ten supported packages as real matrix failures. That revision intentionally predates acompare_bytescompiler crash reproduced with Kani’s firstrustc 1.100.0-nightlysnapshot. The workflow does not patch manifests, bypass MSRV data, or change Rullst’s stable toolchain. Kani cannot verify the proc-macro-onlyrullst-macrostarget. Miri, which only runs on nightly Rust, pinsnightly-2026-08-21(rustc 1.100.0-nightly) and strictly executes 15 named pure-Rust/default-feature scopes. Its matrix excludes nativering, AWS-LC, SQLite, OS-syscall and network/provider execution that Miri cannot interpret; native CI, integration tests and sanitizers remain the applicable evidence for those paths. Therullstumbrella re-export facade and Blog example add no separate interpreter scope. A selected-scope failure fails the run. The Kani Security harnesses prove pure production decisions such as Vault key-ID character policy, DLP buffer admission, ASCII-folded RASP matching, SRI asset limits and bounded Login Guard delay; the IoT matrix also proves the complete CoAP option-component classification. They do not claim that Kani verifieszeroize’s unsupported inline assembly, cryptographic implementations or the entire concurrent middleware implementations. - Mutation testing is manual, split into 80 lossless shards with at most four
running concurrently, and intentionally informational. A new campaign discovers
its selected source’s inventory; the historical 14,391-candidate receipt is not
a permanent limit for later sources. Before the expensive matrix starts, a fail-fast
--list --jsonpreflight verifies the exact unique candidate set; the final aggregate must match that reviewed list, not merely its count. Full mode optionally accepts a completesource_shathat must be an ancestor of the workflow commit, allowing a repaired controller to measure an immutable published release. Provenance distinguishes the measured source from the workflow source. Targeted and recovery modes reject that override; recovery still enforces its reviewed historical digest. The hosted command makes--all-featuresexplicit and.cargo/mutants.tomlapplies the same feature policy locally; the ignored legacy root configuration and its exclusions were not silently activated. Targeted mode accepts exactly one tracked production.rspath so a correction can be retested without restarting the complete workspace campaign. Each targeted run uses its own per-file concurrency group, an explicit per-mutant test timeout (timeout_seconds, default 300 s, which also bounds the unmutated baseline) and a 300 s build timeout, so one hanging mutant cannot consume the job budget. Its optionaltest_scope=libruns only the package’s library unit tests (cargo test --lib) to avoid rebuilding generated projects in slow integration suites; that narrower scope can report a mutant as missed when only an integration test would catch it, and such a mutant still gets a unit test. Each targeted test binary runs under an 8 GiB address-space limit (prlimitas the Cargo test runner), so a mutant that allocates without bound aborts its own tests instead of exhausting the runner. An optionalshard=k/nslice (n at most 16) splits a file that is too slow for one job; the preflight inventory and the aggregate then cover exactly that slice. Every full and targeted shard has the pinned Redis service ofci.yml’s Redis job and setsRULLST_TEST_REDIS_URL, so the Security rate-limit and Core cache inspection Redis contracts run in the baseline and for every mutant (Core queue scheduling and the ORM Redis data matrix start their own testcontainer). The ORM query-cache and hash-scope Redis contracts need the non-strict pool, so the all-feature build compiles them out and no mutation run reaches them. Contract mode covers the adapters that only a wrapper-owned server reaches. Recovery is governed by a committed, versioned policy binding the originating and continuation runs, attempts, branches, workflow identity, measured source SHA, cargo-mutants version, exact shard set and inventory digest. It bisects only authorized incomplete fragments and combines them with successful immutable artifacts. The content-addressed mixed aggregate still has to match the complete reviewed inventory exactly once. Exit statuses for missed and timed-out mutants remain findings; baseline, usage and internal failures do not get normalized into green jobs. The aggregate also fails closed when an artifact is absent, a shard is incomplete or the reviewed full inventory drifts; its conservative percentage never treats a timeout as caught. - Fuzzing pins
nightly-2026-08-21instead of following a moving nightly alias. This verifier-only toolchain does not change the framework’s stable Rust 1.99.1 toolchain or its Rust 1.96 MSRV. Before a release campaign starts, eleven strict preflight jobs compile every target so a stale import or broken fuzz manifest fails in minutes rather than alongside hours of valid campaigns. A five-minute one-target diagnostic is correction feedback only; the evidence-boundary job refuses to call it release evidence. Each of the eleven fuzz packages has a checked-in dependency lock; inventory validation requires it, and locked metadata plus a post-command drift check prevents preflight or campaign jobs from silently resolving a different dependency graph. The parser campaign restarts its ASan process every 30 minutes while retaining one corpus and the full 5.5-hour target budget, preventing instrumentation RSS accumulation from masquerading as a parser crash. - Branch coverage,
cargo-udeps, TSan and ASan share the reviewednightly-2026-08-21analysis snapshot instead of following a moving nightly alias. The first two remain observational/informational; sanitizer failures remain blocking whenever their daily/manual matrix executes. cargo-udepsis weekly/manual and explicitly non-blocking.- TSan and ASan run daily/manual across twelve runtime/domain packages; Messaging runs its integration contract so its concurrent state is actually exercised rather than reporting a zero-test library pass. There is no MSan job in the current sanitizer workflow.
- The manual ZAP workflow materializes, release-builds and migrates a fresh
REST API and a fresh SaaS starter through the real CLI, and release-builds
and migrates the LMS example from the workspace (the LMS starter moved to
examples/lmsin 13.0.0-alpha.4). The generated manifests name the framework crates as a published CLI writes them, and.github/dast-generated-app.pyresolves them from the checkout through[patch.crates-io], so the build is the same before and after a stable release. The SaaS starter runs in production with placeholder Stripe settings (production refuses mock billing; no checkout is reached). One baseline crawls its public pages (pricing, sign-in, registration, password reset); its form submissions fail the CSRF check because ZAP does not return theSecurecookie over plain HTTP. A second baseline sends the session cookie of an account registered for the run with every request (ZAP_AUTH_HEADER), so it crawls the dashboard and password-change pages and submits the forms with a valid CSRF token..zap/saas-starter-account.tsvputs only the POST-only sign-out and billing commands out of scope (signing out everywhere would revoke the scanning session; checkout and portal open a provider session), and the session must still open the dashboard after the scan. These baselines fail on any warning/failure, preserve INFO observations and use no ignored rules. The release blog showcase is scanned separately but remains informational because its documented presentation boundary deliberately uses a relaxed CSP and third-party assets. Its rules retain those external-asset findings for review and reduce only evidenced token/state signals or escaped showcase reflections to INFO; they do not hide findings withIGNORE. These four targets are representative evidence, not coverage of every starter, authenticated role (Nexus administrators are not scanned), browser, proxy or deployment. - Property tests and benchmarks are scheduled/manual evidence. The property workflow preserves the complete all-feature release-mode regression suite in seven parallel shards and separately runs the ORM and Connect property contracts with 10,000 generated cases. The eight published benchmark groups, backed by nine Criterion binaries, emit non-blocking alerts at a 20% regression and feed the public benchmark hub; they are not a promise against every nanosecond-level regression.
Pull-request mutation gate
mutants-pr.yml keeps the campaign’s result from decaying between
campaigns: on every pull request to main that changes Rust, it mutates only
the changed lines and fails when a mutant survives. Resolve a red result
before merging, with a test that fails with the mutant or, when the mutant is
truly equivalent, with an entry in the list below. Branch protection does not
require the check yet. .github/mutants-pr-exclude.txt lists the files it
skips: the workspace example applications, test-only modules and adapters
exercised only by wrappers that own a disposable PostgreSQL, MySQL or
authenticated Redis server. Those files remain the manual campaign’s
responsibility, through its
contract mode.
The CLI’s integration suites build generated projects, which is too slow to
repeat for every mutant, so .github/mutants-pr.sh runs two passes. The
workspace pass tests each mutant with its package’s whole test suite and
leaves cargo-rullst out; the CLI pass mutates only the files listed in
.github/mutants-pr-cli.txt and tests them with the CLI’s library unit tests
(cargo test --lib), the scope of a targeted mutants.yml run with
test_scope=lib. A CLI file joins the list once such a run leaves no missed
mutant apart from accepted equivalents; the other CLI files are not mutated by
the gate.
Accepted equivalent mutants
The v13 focused campaign (targeted runs of security and correctness files
before 13.0.0-alpha.1), the local whole-crate campaign that followed it
(rullst-iot, rullst-privacy, rullst-labs, rullst-supervision), the
security campaign (targeted runs of every rullst-security file), the
rullst-auth campaign (targeted runs of every file outside the PostgreSQL
adapters), the rullst-core campaign (targeted runs of its security and
correctness files and, before 13.0.0-alpha.3, of every remaining file the
first pass measured, with the Redis service), the rullst-nexus campaign
(targeted runs of every file; the all-feature build selects the PostgreSQL
pool, so the PostgreSQL SQL matrix test reaches the database paths wherever
Docker is available),
the rullst-capital campaign (targeted runs of every file; mutants that
only a live Stripe API response can reach are left to provider sandbox
testing), the rullst-messaging campaign (targeted runs of every file,
with the recurring PostgreSQL and Redis Streams contract runs for those
adapters), the service-backed campaign (the passkey-postgres,
auth-recovery-postgres, privacy-postgres and mail-postgres contract
runs, and targeted runs of the Redis rate limiter with the Redis service) and
the sensitive-file campaign (targeted runs of the OAuth/OIDC, attachment,
link-security, tracking, egress and guardrail files of rullst-connect,
rullst-mail and rullst-ai; the peer-address, status and content-type checks
of EgressFetcher::fetch_bytes run only after a connection to a public HTTPS
origin, which the egress policy refuses to loopback test servers, and are left
to live integration testing; in rullst-core, the pool_ready guard of
run_artisan_command needs an already initialized ORM pool, which the
all-feature build can only open against a PostgreSQL server:
rullst-core/tests/artisan_open_pool.rs opens one in a PostgreSQL
testcontainer and runs db:status in a child process, so runs with Docker
measure the guard, and runs without it skip the test unless
RULLST_REQUIRE_TESTCONTAINERS=true), the cargo-rullst campaign (targeted
runs, with the CLI’s library unit tests, of the self-update, audit, add and
consumer-file, doctor, deploy and upgrade files, and before 13.0.0-alpha.4
of the green command, generators/deploy.rs and update/guided.rs; it
covers the files .github/mutants-pr-cli.txt lists, and ROADMAP.md names the
survivors left in the others) and the Studio remote-mode campaign (targeted runs of every
rullst-studio/src/remote file and of the rullst-studio binary with the
package’s tests) gave every missed mutant in their files a test, except these.
Each change has no observable effect in the all-feature Linux build the
campaign measures, so a test cannot tell it apart. Keep this list short: a new
entry needs the same one-line justification. The pull-request gate (mutants-pr.yml) skips an
entry only when its exact cargo-mutants name is also listed in
.github/mutants-pr-equivalents.txt.
| Mutant | Why it is equivalent |
|---|---|
rullst-core/src/queue/deferral/window.rs: TimeWindow::length_ms, > with >= | TimeWindow::daily rejects equal start and end times, so the two never compare equal. |
rullst-core/src/queue/deferral/window.rs: TimeWindow::intervals, day < until_ms with <= | The extra day starts at or after until_ms, so it never yields an interval. |
rullst-core/src/testing/html_normalize.rs: mask_csrf_headers, found < copied with <= | copied indexes the closing quote of a masked value, so no x-csrf-token key can start there. |
rullst-media/src/s3/signing.rs: settings with Default::default() | Adapter URLs hold only unreserved characters, %XX and / without empty or dot segments, so single or double encoding and path normalisation give the same canonical URI. |
rullst-mail/src/facade/driver.rs: DigestHasher::finish with 1 | finish is never called; the digest is read from the SHA-256 state. |
rullst-mail/src/facade/driver.rs: delete the (Some(_), Some(_)) arm of ses_credentials | That arm exists only without the aws-ses feature, which the all-feature build does not compile. |
cargo-rullst/src/generators/audit_report/a11y.rs: attributes, the end index + 1 after > with index - 1 or index * 1 | The tag scanner resumes at the next <; the byte before a closing > cannot open a tag, because a tag name never starts with >. |
cargo-rullst/src/generators/green/procfs.rs: ticks_per_second, > 0 with >= 0 | The clock tick rate is always positive. |
cargo-rullst/src/generators/green/procfs.rs: the non-Unix ticks_per_second with Some(0) or Some(1) | Not compiled on Linux. |
cargo-rullst/src/generators/green/load.rs: worker, green/artifacts.rs: inspect, and green/target.rs: OwnedApp::wait_ready and OwnedApp::stop, the deadline test Instant::now() < deadline with <= | An Instant reading would have to equal the deadline to the nanosecond, and the loop would then take one more short step. |
cargo-rullst/src/generators/doctor/probe.rs: run, stdout.len() > OUTPUT_LIMIT with >= | An output of exactly OUTPUT_LIMIT bytes is cut at its own length, which leaves it unchanged. |
cargo-rullst/src/generators/doctor/database.rs: tcp_reachable, delete the TimedOut | WouldBlock arm | TcpStream::connect_timeout reports its timeout as a TimedOut error whose text is connection timed out, which the catch-all arm passes through unchanged, and never returns WouldBlock on Linux. |
cargo-rullst/src/generators/output_guard.rs: write_output, the NotFound guard of fs::metadata with true | reject_symlink has already returned every other lstat error, and for a path that is not a symlink stat fails exactly when lstat does. |
cargo-rullst/src/generators/output_guard.rs: write_output, the !parent.as_os_str().is_empty() guard with true | tempfile creates its file for the empty directory "" in the current directory, as it does for ".". |
cargo-rullst/src/generators/consumer_files.rs: reject_links, the NotFound guard with true | Any other lstat failure of an ancestor (permission, not a directory, name too long) recurs with the same error when the path itself is read or created. |
cargo-rullst/src/update/project/permissions_unix.rs: xattr_names, size > MAX_XATTR_NAMES with == or >= | Linux never reports a name list longer than 64 KiB, and a list of exactly 64 KiB cannot hold only security.selinux, so has_unreproduced_attribute refuses it with the same error. |
cargo-rullst/src/update/cache/unix.rs: options, NOFOLLOW | NONBLOCK with ^ | The two flags occupy disjoint bits, so OR and XOR give the same value. |
cargo-rullst/src/update/cache/unix.rs: load_at, FILE_LIMIT + 1 with - or * | validate_file has already refused a file longer than FILE_LIMIT. Reading one byte less shortens only a file of exactly FILE_LIMIT bytes, whose body decode refuses either way because it allows 64 bytes less, and a file that grows during the read is refused either way. |
cargo-rullst/src/generators/audit_source.rs: tokenized_views and mark_code_tokens, index < tokens.len() with <=, and tokenized_views, index = last + 1 with last - 1 or last * 1 | At tokens.len() neither attribute lookup finds a token, so the loop steps past the end or stops. Resuming on an item’s last token or the one before it revisits its terminating ; or { .. }, which cannot start #[cfg(test)]; at the end of the input a re-found attribute only adds a range inside the item already blanked. |
cargo-rullst/src/generators/build/upgrade/backup/restore.rs: is_link with false, and its Windows reparse-point test with & replaced by | or ^ or != by == | On Linux lstat never reports a symlink as the regular file or directory that validate_file and validate_directory_creation also require, and the Windows test is not compiled. |
cargo-rullst/src/generators/build/upgrade/backup/restore.rs: restore_from, the index read’s MAX_INDEX_BYTES + 1 and the staging copy’s expected_size + 1 with * | The index and each snapshot have already been bounded by their metadata, so reading one byte more matters only for a file that grows between that check and the read, which a test cannot arrange. |
cargo-rullst/src/generators/deploy_doctor/input.rs: linked with false and its Windows reparse-point test; in read, the ancestor linked(..) || !is_dir(), the file !is_file() || linked(..) || len > MAX_FILE and the opened-file re-check with &&, NOFOLLOW | NONBLOCK with & or ^, and MAX_FILE + 1 with * | On Linux lstat never reports a symlink as a file or directory, and the Windows test is not compiled. A non-directory ancestor fails the final lookup with NotADirectory, and a directory, FIFO or oversized file that passes a weakened check is refused after opening or reading with the same File error. The open flags, the re-check and the extra byte matter only when the file is replaced or grows between lstat, open and the read, which a test cannot arrange. |
cargo-rullst/src/update/project/snapshot.rs: the non-Windows linked with false; in read, NOFOLLOW | NONBLOCK with & or ^, the opened-file re-check !opened.is_file() || linked(..) || len != ... with &&, and MAX_FILE + 1 with * | lstat never reports a symlink as a file or directory, so the type checks refuse links either way. read has already refused links, special files and files over 64 MiB, so the open flags, the re-check after opening and the extra byte matter only when the file is replaced or grows between lstat, open and the read, which a test cannot arrange. |
rullst-studio/src/remote/history.rs: Sample::from_outcome, window.requests > 0 with >= | With no request the rate is 0 / 0, which finite drops, so the sample stores no rate either way. |
rullst-iot/src/coap.rs (CoapRequest::encode twice, append_option), rullst-iot/src/mqtt.rs (MqttPublish::encode) and rullst-iot/src/i2c.rs (I2cHelper::try_build_read_frame): | with ^ | The operands occupy disjoint bits, so OR and XOR build the same byte. |
rullst-iot/src/coap.rs: CoapRequest::encode, payload.len() > MAX_COAP_DATAGRAM_BYTES with == or >= | CoapRequest::payload already bounds the payload, and ensure_room rejects the same lengths with the same error. |
rullst-iot/src/mqtt.rs: MqttPublish::encode, remaining_length > MQTT_MAX_VARIABLE_BYTE_INTEGER with == or >= | The builders keep topic and payload far below that limit, and variable_byte_integer repeats the check with the same error. |
rullst-privacy/src/age_assurance/challenge_tokens.rs: ChallengeTokens::open_with_clock, token.len() > MAX_TOKEN_BYTES and payload.len() > MAX_PAYLOAD_BASE64 with == or >=, the || before payload.is_empty(), the payload bound and tag.len() != 43 with &&, and MAX_PAYLOAD_BASE64 with its + or / replaced by * | These pre-authentication bounds only reject early what key lookup, tag verification, the 4096-byte decoded limit or JSON parsing reject with the same InvalidChallengeToken: a well-formed token is far below 8192 bytes, and 5463 base64 characters already decode to 4097 bytes. |
rullst-privacy/src/age_assurance/sqlite.rs: SqliteReplayStore::open, the error.kind() != NotFound guard with false | Every other metadata failure (permission, name length, symlink loop) also makes the SQLite open fail with the same StoreUnavailable. |
rullst-privacy/src/consent/gate.rs: allows_with_clock, completed < record.valid_until() with <= | A grant with completed >= valid_until has already returned false, so the two never compare equal there. |
rullst-labs/src/sqlite/completion.rs: finish, lease.until <= tx.now || record.view.expires_at <= tx.now with && | narrow_deadline with the earlier of the two returns the same Expired. |
rullst-labs/src/sqlite/jobs.rs: cancel, revision <= 0 || record.view.revision != revision with && | save_job updates only the row at that revision, so a stale revision still returns Conflict. |
rullst-labs/src/sqlite/jobs.rs: load_job, body.len() > 32796, and seal_record, body.len() > 32768, each with == or >= | Bounded record fields serialize far below 32 KiB, and a longer stored body fails authentication with the same Integrity. |
rullst-labs/src/sqlite/jobs.rs: payload, the || before the scope and id comparisons with && | The exercise digest covers scope, id and revision, so none of them can differ alone. |
rullst-labs/src/sqlite/retention.rs: expire_queued, either || with &&, and purge_terminal, the || after the state match with && | The SQL selection, load_job’s column checks and Record::validate already guarantee a queued, expired, unleased record, or an unleased one that is neither queued, running nor Uncertain (which always holds its lease). |
rullst-supervision/src/exam/observation.rs: Observation::from_code, (code - 10) % 4 with + | (code + 10) % 4 equals (code - 10) % 4, because 20 is a multiple of 4. |
rullst-supervision/src/sqlite/events.rs: events, and rullst-supervision/src/sqlite/observations.rs: observations, expires_at > MAX_TIME with == or >= | An event is received before its session expires, at least an hour before the session’s retention ends at or before MAX_TIME, so with its fixed retention it always expires before MAX_TIME. |
rullst-supervision/src/sqlite/observations.rs: record_observation, both source match guards with true | record_browser and record_capture pass only browser or capture observations with the browser source, and analyze only presence or audio findings with an adapter source. |
rullst-supervision/src/sqlite/transaction.rs: Operation::begin, now < before || now < previous_time with && | finish checks the time against the same high-water mark and returns the same Clock. |
rullst-supervision/src/sqlite/mod.rs: resolved_path, both metadata match guards with false | open checks for a regular file again, initialize creates the file exclusively and fails on any existing entry, and every other metadata error also fails the open with the same Storage. |
rullst-auth/src/auth/app_key.rs: parse_dotenv, delete the dotenvy::Error::Io arm | The parser reads an in-memory &str, and valid UTF-8 read line by line cannot raise an I/O error. |
rullst-auth/src/auth/passkey/ceremony.rs: ChallengeStore::issue, challenge.expires_at > now with >= | It keeps only a challenge whose expiry equals a later Instant::now() reading to the nanosecond, which consume already rejects as expired. |
rullst-auth/src/auth/passkey/shared/manager.rs: SharedPasskeyAuth::start_authenticate, allowed.is_empty() || allowed.len() > MAX_CREDENTIALS with && | CeremonyIntent::new rejects an empty or 33-entry authentication allowlist with the same InvalidInput before the store is called. |
rullst-auth/src/auth/passkey/shared/manager.rs: validate_passkey, the || before first() != Some(&4), and rullst-auth/src/auth/passkey/sqlite.rs: validate_passkey, ||, each with && | A key that from_sec1_bytes accepts has either 65 bytes and tag 4 or neither, so a key failing only one check fails parsing with the same error. |
rullst-auth/src/auth/passkey/verification.rs: verify_assertion, FLAG_ATTESTED_CREDENTIAL_DATA | FLAG_EXTENSION_DATA with ^ | The flags occupy disjoint bits, so OR and XOR build the same mask. |
rullst-auth/src/recovery/connection.rs: the fallback configure_postgres and configure_sqlite with Ok(()) | They compile only without recovery-postgres or without both SQLite families, which the all-feature build does not compile. |
rullst-auth/src/recovery/email_login/delivery.rs: <impl Drop for WireNotice>::drop with () | Zeroizing a buffer that is being freed has no effect safe code can observe. |
rullst-auth/src/recovery/email_login/types.rs: safe_path, part == "." || part == ".." with && | The byte allowlist that follows already rejects every .. |
rullst-auth/src/recovery/store.rs: SqlRecoveryStore::connect, the || before the recovery-postgres clause with && | With every feature enabled both feature clauses are always false, so joining them with && changes nothing. |
rullst-security/src/telemetry.rs: SecurityStore::push_live_event, events.len() > MAX_LIVE_EVENTS with >= | Truncating a buffer that holds exactly 50 events to 50 changes nothing. |
rullst-security/src/telemetry/event.rs: security_event_schema_version with 1 | SECURITY_EVENT_SCHEMA_VERSION is 1. |
rullst-security/src/telemetry/event.rs: truncate_utf8, boundary > 0 with >= | is_char_boundary(0) is always true, so the loop stops at 0 either way. |
rullst-security/src/sentinel/pow/codec.rs: decode_token, token.is_empty() || token.len() > 128 with &&, and > with == or >= | Decoding rejects every token that does not decode to exactly 82 bytes with the same InvalidToken. |
rullst-security/src/siem/spool.rs: decode_file, and rullst-security/src/siem/authenticated/codec.rs: read_and_verify, events.len() + 1 in the invalid frame offset error of cursor.checked_add(relative_end) with - or * | A line end found inside the buffer cannot overflow usize, so the closure never runs. |
rullst-security/src/recovery_codes.rs: GeneratedRecoveryCodes::drop with () | Zeroizing the plaintext codes changes only memory that is freed right after; safe code cannot read it. |
rullst-security/src/mfa.rs: verify_totp_step_at, code.len() != 6 || !all digits with && | Every other input fails the constant-time comparison with the six-digit expected code. |
rullst-security/src/mfa.rs: decode_base32 and generate_totp_at_counter (three times), | with ^ | The operands occupy disjoint bits, so OR and XOR build the same value. |
rullst-security/src/schema_guard.rs: StrictJsonVisitor::expecting with Ok(Default::default()) | serde_json calls a visit method for every JSON value, so the message is never formatted, and inspect_json_payload maps every error to fixed text. |
rullst-security/src/dlp/masking.rs: mask_database_passwords, pass_end + 1 with - or * | The next scheme search starts at or just before the @, where no scheme can begin. |
rullst-security/src/log_redactor.rs: identifier_continuation, end + 1 with - | A _ or . followed by a non-alphanumeric byte then yields an empty last component, which is skipped like the identifier without it: neither byte is a separator, quote or space. |
rullst-security/src/timing_guard.rs: TimingScope::finish, elapsed < target_duration with <= | Sleeping for a zero remainder returns immediately. |
rullst-security/src/login_guard.rs: record_login_failure, the window match guard with true | prune_expired has just removed every counter whose last attempt is a window or more old. |
rullst-security/src/rate_limit.rs: is_rate_limited_in, the retention < with <= | Two Instant::now() readings would have to differ by exactly the window, and an entry kept at that instant restarts its count on its next use. |
rullst-core/src/security/pii.rs: decode_json_char, delete the ", \, /, b, f, n, r or t arm | Each of these escapes decodes to a character that, like the opaque stand-in, is neither a digit, a card separator, an address character nor @, and both keep the escape’s original spelling. |
rullst-core/src/security/pii.rs: mask_chars, idx - start with + and username_len > 1 with >= | A local part of at most one character has nothing to mask after its first character, and skipping to the end of the domain passes only domain characters, never another @. |
rullst-core/src/security/pii.rs: mask_chars, the domain slice start idx + 1 with idx * 1 or idx - 1 | The domain holds a dot, so the leading @ never reaches the top-level domain; when its only dots are trailing ones, the text after the last dot starts with @ and fails both domain tests. |
rullst-core/src/security/csrf_multipart.rs: scan, available < first.len() with > | A prefix shorter than the first delimiter reaches the loop’s two-byte read past its end, which returns the same Incomplete. |
rullst-core/src/security/csrf.rs: is_csrf_exempt_path, the first two || with && | /robots.txt, /sitemap.xml and /favicon.ico stay exempt through their .txt, .xml and .ico suffixes. |
rullst-core/src/queue/mod.rs: the default QueueDriver::push_at, available_at > SystemTime::now(), and Queue::dispatch_at, delay > MAX_SCHEDULE_DELAY, each with >= | They differ only when the requested time equals a clock reading taken inside the call, which a caller cannot arrange. |
rullst-core/src/queue/worker/execution.rs: record_outcome, the error.is_panic() guard with true | execute_job handles every cancelled handler before it calls record_outcome, so the only join error left is a panic. |
rullst-core/src/storage/tenant.rs: confine_local_root, the !create && NotFound guard with false or without ! | Below a missing tenant directory, get, metadata and delete then fail with the same NotFound that the operation itself reports; put creates the directory first. |
rullst-core/src/cache/memory.rs: MemoStore::get, now < entry.expires_at, and MemoStore::set, the expiry retain’s <, each with <= | An Instant reading would have to equal the entry’s expiry to the nanosecond. |
rullst-core/src/lifecycle.rs: begin_draining, in_flight_requests() == 0 with !=, and release_request, && with || | wait_for_drain waits only on a draining or stopped lifecycle and re-reads the count after every wake-up, so a wake-up that no waiter can receive, or an extra one, changes nothing. |
rullst-core/src/storage/cloud/multipart/codec.rs: MultipartStorage::open, checkpoint.0.len() > MAX_TOKEN with == or >=, and bytes.len() < 28 with <= | No authentic checkpoint reaches 256 KiB (256 receipts and a 2,048-byte upload id stay under 180 KiB), and a 28-byte token has an empty plaintext; both fail with the same InvalidCheckpoint. |
rullst-core/src/testing/snapshot.rs: line_diff, the deletion test’s table[i * width + j + 1] with j * 1 | Without a match, table[i][j] is the larger of table[i + 1][j] and table[i][j + 1], so table[i + 1][j] >= table[i][j] holds exactly when table[i + 1][j] >= table[i][j + 1]. |
rullst-core/src/queue/deferral/planner.rs: lowest_intensity, the tie-break overlap_start < best_start with <= | A tie at the same start stores the same start and value again. |
rullst-core/src/live/recovery/types.rs: the duplicate-free field visitor’s expecting with Ok(Default::default()) | LiveCommand::decode turns every deserialization error into the same Invalid, so the expectation text is never seen. |
rullst-core/src/rpc.rs: request_contract_failure, delete the BodyTooLarge arm | to_bytes already refuses a body over the same limit with the same 413 and code, so decoding never reports it. |
rullst-core/src/rpc.rs: RpcFailure::from_detail with Default::default() | Compiled only for wasm32. |
rullst-core/src/rpc.rs: the test helper failure_for, 64 * 1024 with + | Test code: every failure envelope it reads is far below 1,088 bytes. |
rullst-core/src/storage/cloud/multipart/protocol.rs: parse, delete the allow_dtd field | roxmltree’s default ParsingOptions already rejects a DTD. |
rullst-core/src/live/recovery/session.rs: run, delete the Ok(Ok(())) arm | A session ends normally only when the peer closed: tungstenite answers a close frame itself, so the code chosen here never reaches the peer. |
rullst-core/src/live/recovery/session.rs: send_snapshot, encoded.len() > 512 * 1024 with == or >= | A snapshot holds at most 64 KiB of HTML, which JSON escaping expands to under 400 KiB. |
rullst-core/src/cache/memory/exact_json.rs: Display for Inexact with Ok(Default::default()) | is_exact_json only checks whether serialization succeeds; the error text is never formatted. |
rullst-core/src/radar.rs: get_windows_memory_mb, get_windows_process_cpu_usage, filetime_100ns, calculate_windows_cpu_percent, and the Windows and other-platform get_process_cpu_usage | Not compiled on Linux. |
rullst-core/src/feature/db.rs: DbFeatureDriver::resolve_with, the freshness test Instant::now() < entry.expires_at, and remember, the eviction retain’s <, each with <= | An Instant reading would have to equal the entry’s expiry to the nanosecond. |
rullst-core/src/cache.rs: MemoryDriver::cleanup_if_due, the sweep’s now < expires_at, and inspect, the filter’s expires_at > now, with <= and >= | An Instant reading would have to equal the entry’s expiry to the nanosecond. |
rullst-core/src/storage/local_paths.rs: reject_symlink_components, the NotFound | NotADirectory guard with true | Any other symlink_metadata failure (permission, name length) recurs in the canonicalize, create_dir_all or write that follows, with the same Io text. |
rullst-core/src/telemetry/distributed/processor.rs: MinimizedProcessor::on_start with () | The wrapped BatchSpanProcessor::on_start ignores its arguments (opentelemetry_sdk 0.33). |
rullst-core/src/server/dylib_loader.rs: is_expected_loaded_library_removal_error with true or false | Not compiled on Linux. |
rullst-core/src/config/project_setting.rs: parse_dotenv_entries, delete the dotenvy::Error::Io arm | The entries are read from a &str, which never fails with an I/O error. |
rullst-nexus/src/nexus/crud/form.rs: form_html, the Password guard with false | render_field_widget renders a Password input empty and without a placeholder, whatever value or state it receives. |
rullst-capital/src/invoice.rs: money_to_minor, (scaled - rounded).abs() > 1e-7 with >= | When scaled rounds to at least one minor unit, scaled - rounded is a multiple of 2^-53 and never equals the f64 1e-7, so the two differ only for an amount that rounds to zero minor units, which rounded < 1.0 rejects either way. |
rullst-capital/src/invoice/pdf.rs: render_pdf, bytes.len() > MAX_PDF_BYTES with == or >=, and the || with && | The embedded font is at most 8 MiB and a bounded invoice adds well under 1 MiB, so the document never reaches 16 MiB, and printpdf always writes the %PDF- header. |
rullst-capital/src/providers/mod.rs: http_client with a leaked default client | Adapters only build requests with it; execute always sends them through the bounded client from http::client. |
rullst-capital/src/providers/stripe.rs: create_customer_portal, || with && | require_mock_operation accepts the same empty and mock_* keys and the same fixture URL is returned; every other key fails there with the same error. |
rullst-orm/src/value.rs: the SQLx Any pool’s temporal::date_time, naive_date_time, date and time with String::new() or "xyzzy".into() | They exist only without a strict driver feature, and the all-feature build selects the PostgreSQL pool; the_any_pool_binds_dates_as_iso_text checks their exact text in default-feature builds. |
rullst-orm/src/schema/schema_builder.rs: prepare_postgres_enums, delete ! in the !definitions.is_empty() guard | That guard exists only without strict-postgres, which the all-feature build enables; postgres_enum_preparation_validates_before_any_statement covers it in default-feature builds. |
rullst-orm/src/pool/dsn.rs: percent_decode_utf8, (high << 4) | low with ^ | The high and low hex digits occupy disjoint bits, so OR and XOR build the same byte. |
rullst-orm/src/privacy.rs: the default ComplianceModel::personal_fields with vec![] | The default already returns an empty vector. |
rullst-orm/src/polyglot/turso/model.rs: the default TursoModel::opaque_columns with Vec::leak(Vec::new()) | The default already returns an empty slice. |
rullst-orm-macros/src/feature_gates.rs: redis and ai with Default::default() | With every feature the runtime gates are on and the feature is enabled, so both return an empty token stream, which is the default. |
rullst-orm/src/polyglot/recovery/codec.rs: base64url_value, the digit arm’s byte - b'0' with + | The value is read only for its low four padding bits, and the two results differ by 96, a multiple of 16. |
rullst-orm/src/polyglot/qdrant/mod.rs: LiveQdrant::send_json, bytes.len() > MAX_REQUEST_BYTES with == or >= | No request reaches 4 MiB: vectors hold at most 65,536 values and payloads at most 1 MiB, so the body stays near 2 MiB; a test still sends a 1 MiB payload. |
rullst-orm/src/outbox.rs: Outbox::install, case_insensitive_keys > 0 with >= | The MySQL key-collation conversion is idempotent: run on keys that are already ascii_bin it leaves the same columns and only costs a table rebuild. The MySQL matrix converts a legacy table, which kills the == and < mutants. |
rullst-orm/src/schema/migration_lock.rs: MigrationLock::release with () | The lock lives in the dedicated connection’s session. The replaced body still drops self, which closes the socket and ends the session, freeing the lock as the graceful close does. |
rullst-messaging/src/webhooks/signature.rs: WebhookSignature::from_headers, the || before kind.len() > 128, timestamp.len() > 12 or signature.len() != 46, and tag.len() != 32 || …, each with && | MessageId::from_stored, EventKind, the canonical timestamp parse and the strict v1= decode repeat each length check with the same InvalidSignature, and 43 strict base64url characters always decode to 32 canonical bytes. |
rullst-messaging/src/sqlite/storage/crypto.rs: open, the minimum sum with a + replaced so that it shrinks, and envelope.len() < minimum with <= or == | The shortest authentic envelope is 38 bytes; a shorter one fails the key-ID, nonce or tag parse with the same StorageAuthenticationFailed. |
rullst-messaging/src/schedules/crypto.rs: open, value.len() > MAX_CONTENT_BYTES + 128 with == or >= or its + with *, the || after it with &&, and a + in 8 + 1 + 1 + 12 + 16 replaced so that the sum shrinks | Sealed values span 38 to 131,173 bytes, so each change only moves which later parse or authentication step rejects a value, with the same Encryption. |
rullst-messaging/src/schedules/delivery.rs: verify, lease.namespace != … || now >= lease.expires with && | Occurrence IDs digest their namespace, so another namespace’s lease is not found by its ID, and an expired lease fails the commit deadline; both return the same InvalidLease. |
rullst-messaging/src/wire.rs: FIXED_BYTES, a 2 + 2 with 2 * 2, and Reader::is_finished with true | 2 * 2 equals 2 + 2; decode re-encodes the envelope and compares it with the frame, which rejects trailing bytes with the same InvalidWireEnvelope. |
rullst-messaging/src/redis_streams/transport.rs: Remote::decode, delete the "missing" | "corrupt" arm | The catch-all arm returns the same CorruptStorage. |
rullst-connect/src/provider/id_token.rs: validate_claims_with_presenters, the || before nonce.is_empty() with && | verify_nonce compares SHA-256 digests, so a nonce empty on one side only already fails it, and two empty nonces still satisfy the joined condition. |
rullst-mail/src/security.rs: redact_labelled_private_keys, MAX_PEM_LABEL_BYTES + 1 with * | Reading at most 64 label bytes cuts a longer label inside its run of label bytes, so the ----- check that follows fails as the length check would. |
rullst-mail/src/security.rs: href_values, value_start + length + 1 with - or * | The scan resumes on the closing quote or the byte before it (the opening quote for an empty value), and no href match can contain a quote. |
rullst-mail/src/inspection/sniff.rs: root_element, open < close with <= | Both are positions just after one byte, a [ and a >, so they are never equal. |
rullst-ai/src/ai/egress.rs: EgressPolicy::strict with Default::default() | strict already returns Self::default(). |
rullst-ai/src/ai/egress.rs: is_public_ipv6, is_unspecified() || is_loopback() with && | :: and ::1 lie outside 2000::/3, which the next clause rejects. |
rullst-ai/src/ai/guardrails/markdown_inline.rs: open_tag_end, the self-closing then_some(index + 1) with - or * | The tag then ends on or just before its /; the byte before that / is whitespace, a quote or a name byte, never a bracket. |
rullst-ai/src/ai/guardrails/markdown_images.rs: definition_destination, the <...> end end + 1 with * | It drops only the closing > or line ending, which neither the scheme nor the authority check reads. |
rullst-ai/src/ai/guardrails/markdown_code.rs: indentation, the tab step 4 - column % 4 with 4 + column % 4 | Indentation is only compared with 4, and either step takes a tab to at least four columns. |
rullst-ai/src/ai/guardrails/tax_ids.rs: token_end, bytes.get(index + 1) with index - 1 | A separator then joins when a digit precedes it instead of follows it, but the end moves only on digits and a second separator stops both scans, so every token ends at the same digit. |
rullst-ai/src/ai/guardrails/tax_ids.rs: is_valid_tax_id, the CNPJ all_digits && token.len() == 14 with || | digit_values still requires exactly 14 digits, which only a 14-digit token or the CNPJ layout holds. |
Timed-out mutants are not counted as caught, and cargo-mutants reports them separately. The campaign rewrote the scanning loops of the WAF detector, the RASP decoder, the HTML snapshot normaliser and the accessibility scanner so that every step consumes input, and bounded the WAF linear-time test, which removed their timeouts. The core campaign ran the queue worker, realtime, multipart CSRF, byteranges and long PII masking tests against a deadline instead, which turned their timeouts into caught mutants; the CLI campaign bounded the update cache’s FIFO test the same way. The remaining ones turn forward progress into a loop that never ends without allocating, in code every test of the file reaches, so no single test can fail first:
rullst-core/src/error_console/store.rs:Store::recordwithlen() >= MAX_ENTRIESreplaced by<pops an empty queue forever.cargo-rullst/src/generators/build/upgrade/rules/tokens.rs: the token scanner’slevelandpathloops, where an index step (+= 1,+= 2,next + 1,end + 1,end + 2) becomes-or*, orpathreturns0or1, and the scan revisits the same token forever.cargo-rullst/src/generators/audit_source.rs: the token scans oftokenized_views,item_end,mark_code_tokensanddoc_attribute, where an index step (+= 1) becomes-=or*=,item_endreturns0or1, ordoc_attributereturnsSome(0)orSome(1)or itsnext + 1becomes-, and the scan revisits the same token forever.rullst-security/src/log_redactor.rs: the bearer, identifier, separator and escaped-quote scans, where an index step (+= 1,+= 2) becomes-or*, a key offset (cursor + offset,key_start + key.len()) becomes-or*, oridentifier_continuationreturns0or1, and the scan finds the same key or byte again forever.rullst-security/src/dlp/byteranges.rs:Lines::next, where the non-empty filter is negated or the next-line offset (start + offset + 1) becomes-or*, and the reader returns the same line forever;rullst-security/src/dlp/masking.rs: the AWS-key and database scans, wherecursor + offsetbecomes*or-, or+ scheme.len()becomes-, and the scan restarts before the same match.rullst-core/src/security/pii.rs:measure_digit_runreturning(0, 0),(0, 1),(1, 0)or(1, 1), itsseparators < 3with==or>, or itsend += 1with*=or-=, and the address and JSON-string scans ofmask_charsandmask_json_strings, wherestart -= 1becomes/=,end += 1oridx += 1becomes*=, or the dotted-domainend += 1becomes-=, so the card, address or string scan never advances; andrullst-core/src/security/pii_byteranges.rs:Lines::next, where the next-line offset becomes-or*, as in the DLP reader above.rullst-core/src/server/dev_telemetry/recorder.rs:push_boundedwithentries.len() >= capacityreplaced by<pops an empty queue forever, in code every recorded request or query reaches.rullst-core/src/scheduler.rs:shutdown_requestedreturningtrueorfalse, andwait_for_shutdownreturning at once or without its!, so a task loop never runs or never sees shutdown, and the scheduler, server and supervision tests that start and stop a scheduler wait forever.rullst-security/src/login_guard/state.rs:pop_earliestreturningNoneorSome(Default::default()), ormake_roomwith&&replaced by||, so pruning or eviction never removes an entry; andrullst-security/src/rasp/user_agent.rs: the version scan’sindex += 1replaced by*=.rullst-mail/src/security.rs: the redaction scans, wherestart + relative_endinredact_values_afterbecomes-,offset + relativeinredact_aws_access_keysorredact_labelled_private_keysbecomes*, orstart + prefix.len()becomes-or*, and thehref_valuesscan, whereindex += 1inskip_whitespaceor the non-matchpos += 1becomes*=, or thepos += 4after anhrefwithout=becomes-=; each scan restarts before the same match.rullst-ai/src/ai/guardrails/tax_ids.rs:mask_tax_idswithout the!of its digit test or withstart += 1as-=or*=,token_endreturning0or1, and itsindex += 1steps as-=or*=;rullst-ai/src/ai/guardrails/markdown_inline.rs:code_span_hides_bracketwith either backtick test negated,index += runas-=or*=orindex += 1as*=, and the whitespace steps ofopen_tag_endas*=;rullst-ai/src/ai/guardrails/markdown_images.rs: theindex += 1steps ofunescaped_closeandlabel_endas-=or*=; andrullst-ai/src/ai/guardrails/markdown_code.rs: the index steps oflines,mark_fencedandspan_contentsas*=, andblank_codewithcode[group_start] || is_blank(...)as&&, which stops on a blank line. The guardrail tests reach these scans, which then never finish.
rullst-security/src/timing_guard.rs: TimingScope::finish with the jitter
% replaced by + does not loop: it sleeps for up to 72 minutes in the
real-time padding tests.
A targeted run sets each test binary’s address space to 8 GiB, so a loop that
does allocate aborts its tests and counts as caught instead.
rullst-ai/src/ai/guardrails/markdown_code.rs: blank_code with
group_start += 1 replaced by *= does allocate, but it repeats one line
forever; on a blank line the output grows one byte per turn and reaches that
limit only after the timeout.
Owned-server contract mutation runs
Some adapters are reached only by ignored contract tests that a wrapper runs
against a disposable Docker server it owns, so a full or targeted run reports
their database-bound mutants as missed. mode=contract measures them: it runs
.github/check-<contract>.py --mutants REGEX [--shard k/n] on a hosted runner
and uploads mutants.out as mutants-out-contract. The wrapper starts its
server, mutates only its adapter files and runs only the first (exercise)
phase of its contract tests, one at a time, never the restart phase.
.github/mutation_contract.py is the Cargo target runner for those tests: it
resets the server before every test binary (a recreated database of the same
name, no leftover runtime roles, ALTER SYSTEM RESET ALL, or FLUSHALL for
Redis), because a mutant can stop a test halfway and leave a role, a disabled
fsync or a full Redis behind. Missed and timed-out mutants are findings; a
failed baseline, a baseline that did not run every contract test, a failed
reset or an incomplete classification fails the run. Because only the contract
tests run, a mutant that a unit test of the same file catches can still be
reported as missed: compare it with a targeted run of that file. One build
directory serves every mutant, so the job compiles incrementally, without
sccache (which refuses incremental builds), and rebuilds a mutant in about a
second.
contract | Mutated files | Contract tests |
|---|---|---|
passkey-postgres | rullst-auth/src/auth/passkey/shared/manager.rs, shared/postgres/operations.rs | postgres_contract |
auth-recovery-postgres | rullst-auth/src/recovery/connection.rs, store.rs, email_login/guard.rs, email_login/storage.rs, api_tokens/storage.rs: the files with PostgreSQL-only branches | recovery, sessions, email login, API token (with HTTP) and the exercise phase of the three restart journeys; not the browser journey |
privacy-postgres | rullst-privacy/src/age_assurance/postgres.rs, consent/postgres.rs | shared_replay_contract, shared_consent_contract |
mail-postgres | rullst-mail/src/suppression/postgres/** | suppression contract and restart exercise |
recurring-postgres | rullst-messaging/src/schedules/** (schedules-postgres) | recurring contract and restart exercise |
messaging-redis | rullst-messaging/src/redis_streams/** | the password, TLS, fault, ordering, outbox and bounds contracts and the AOF restart exercise |
connect-sql | rullst-connect/src/challenge/sql/postgres.rs, sql/mysql.rs (the wrapper also owns a MySQL server, which each contract resets by dropping its table) | postgres_challenge_contract, mysql_challenge_contract |
orm-sqlite | rullst-orm/src/** and rullst-orm-macros/src/**, test files excluded; select files with mutants_regex (for example ^rullst-orm/src/pool/savepoint\.rs:) | every rullst-orm test target, built with drivers-all (the SQLx Any pool) |
orm-polyglot | the same files; select the optional-backend modules (polyglot, scout, redis_data, query_cache) with mutants_regex | every rullst-orm test target, built with drivers-all and the Redis, MongoDB, SurrealDB, Turso, Scout HTTP and Qdrant features, with an owned Redis server for the RULLST_TEST_REDIS_URL contracts |
orm-sql-servers | the same files; select the PostgreSQL and MySQL/MariaDB branches with mutants_regex | matrix_postgres, matrix_mysql and matrix_mariadb, built with drivers-all; their testcontainers start a disposable server per test binary, and a server that cannot start fails the run |
orm-strict-postgres | the same files | matrix_postgres, built with strict-postgres only, which also exercises the native enum types and typed binds of that pool |
orm-strict-mysql | the same files | matrix_mysql, built with strict-mysql only |
orm-polyglot-servers | the same files; select the optional backends’ live paths with mutants_regex | the MongoDB, SurrealDB, document-recovery, Qdrant, Meilisearch, Redis data and Turso testcontainers matrices, built like orm-polyglot |
orm-sqlite owns no server. Targeted and full runs build the workspace with
every feature, which selects the strict PostgreSQL pool and compiles the
SQLite behaviour tests of rullst-orm out, so they report the mutants that
only a database statement reaches as missed. This contract builds the ORM
with drivers-all instead and runs its whole suite on SQLite for every
mutant, including the code #[derive(Orm)] generates; the testcontainers
matrices are skipped. Code compiled only with every feature, such as the
PostgreSQL-only branches, is measured by the targeted runs. orm-polyglot
adds the optional backends, whose tests use mocks and loopback servers, and
runs the query-cache and Redis hash-scope contracts against a Redis server it
owns; DuckDB, whose bundled build is large, stays with targeted runs. orm-sql-servers
measures the dialect branches (placeholders, migration locks, collations and
payload types) against real servers; every mutant waits for three of them, so
select only those branches. orm-strict-postgres and orm-strict-mysql run
one of those matrices in the build of a strict pool, for code that the Any
pool does not compile.
The MySQL statement files rullst-capital/src/quota/sql/mysql.rs and
rullst-ai/src/ai/memory/sql/mysql.rs hold only string constants, so
cargo-mutants generates no mutant for them. The privacy URL policy in
postgres_connection.rs and the capacity check have unit tests. So do the
mail adapter’s clock, event-time, stored-time and URL checks, the privacy
choice-code decoding, and, in passkey-postgres and
auth-recovery-postgres, most branches of manager.rs and the recovery
files: a contract run reports those mutants as missed, and a targeted run
of the file catches them.
Fuzzing and OSS-Fuzz
The v13 manual fuzzing.yml matrix covers all 42 declared libFuzzer targets
(the immutable v12 line retains 40):
Core 12, ORM 5, Security 7, Connect 3, Mail 4, AI 3, IoT 3, Capital 1, Nexus 1,
Studio 1 and Privacy 2. The checked-in .github/fuzz-targets.json is validated against
every */fuzz/Cargo.toml, corresponding lockfile and source file before the
manual campaign can fan out. Release mode verifies input-equivalent historical
results and compiles the remaining targets in package-level preflight jobs
before starting any long campaign. Campaign jobs restore and save versioned
per-target corpora in the Actions cache and share one content-addressed
compiler-cache namespace; campaign failures retain their exact reproducer.
The weekly corpus-sync.yml warm-up was removed in 13.0.0-alpha.4: the
repository cache runs above its 10 GiB limit, so no saved corpus survived to
the next weekly run or to a release campaign (the weekly runs of 11, 18 and 25
October 2026 and the release campaign of 24 October all reported “Cache not
found”), and its one-minute runs never produced a reproducer. The same
eviction applies to the corpora the campaign saves. A clean
run retains its original source SHA, target, corpus, toolchain and time budget;
reuse adds a separately verified relationship to the candidate inputs and
never claims a fresh execution. Diagnostic mode is limited to one exact inventory target for five
minutes and is never counted as the complete release gate.
The oss-fuzz/projects/rullst directory is a local integration draft. It is not
proof of upstream acceptance, continuous ClusterFuzz execution, or coverage of
all 42 targets; its helper build must be completed and validated against the
official OSS-Fuzz repository before submission. The integration is worth
finishing, but a “100% first-pass acceptance” promise is not meaningful and
should not be made.
Supply chain and release provenance
All direct third-party GitHub Actions references in this repository’s workflow
files are pinned to full commit SHAs. A pinned composite action can still carry
its own transitive downloads or references, so blocking integrations must also
be reviewed for that behavior. RustSec exceptions are limited by deny.toml
and documented with owners, controls, and expiry dates in
docs/src/security-advisory-exceptions.md.
scorecards.yml runs the pinned OpenSSF Scorecard action on main pushes and
weekly, uploads SARIF to GitHub code scanning, and publishes OIDC-authenticated
results to the public Scorecard API so the README badge follows the latest
completed analysis. The numeric score is supply-chain evidence, not a security
certification.
release.yml is tag-only. It verifies source, validates the exact semantic tag
against every publishable crate, packages before the first publish, and creates
a tag-bound evidence bundle containing the lockfile, Cargo metadata, CycloneDX
1.5, Cargo Audit JSON, dependency policy, bounded compliance evidence, advisory
exceptions, commit context, checksums, and release notes rendered from the
exact matching changelog section: the section’s introduction and “Known
limitations”, plus a link to the full section at the tag (the whole section
when it has no introduction). The .crate archives and evidence receive
GitHub’s SHA-pinned build-provenance attestation. This does not by itself
establish a project-wide SLSA level, Sigstore Cosign binary signing, independent
review, or regulatory compliance.
workflow-lint.yml validates all workflow syntax, GitHub expressions, and
embedded shell with Actionlint 1.7.7. Its container is pinned to an immutable
linux/amd64 digest, just like third-party GitHub Actions are pinned to full
commit SHAs.
architecture.yml is repository-owned and deterministic. It rejects any
internal dependency edge or optionality change that is not reflected in the
reviewed crate-architecture-policy.json. The earlier TangleGuard integration
was removed because its composite action downloaded an unversioned latest
binary without a repository-pinned checksum, which was unsuitable for a
blocking supply-chain gate.
Reviewed Action updates on the v12 maintenance line
Keep CodeQL init, analyze, and upload-sarif on one reviewed Action
revision. Dependabot groups these sub-actions, and the local pin validator
rejects mixed CodeQL revisions inside a workflow. Review the scanner bundle
change as well as the wrapper SHA; the 4.38.0 update selects CodeQL 2.27.0.
setup-rust-toolchain 2.0.0 changes warning enforcement from RUSTFLAGS to
CARGO_BUILD_WARNINGS (Cargo 1.97+). The v12 migration explicitly retains
rustflags: "-D warnings" and build-warnings: "", preserving the previous
compiler flags, strict warning behavior and compiler-cache inputs. This does
not disable Clippy’s explicit -D warnings. The MSRV job still uses a separate
toolchain installer and Rust 1.96.0. Adopting the new Cargo warning mechanism
is a separate measured migration, not an implicit side effect of updating an
Action. See the upstream migration notes.
TruffleHog’s composite Action pin does not pin its default latest scanner
image. The workflow therefore also specifies the reviewed 3.97.4 multi-platform
image digest. Future scanner updates must review and change that digest;
Dependabot updating the wrapper alone is insufficient. The scan’s existing
scope and verified-secret failure policy remain unchanged. These automation
updates do not change published crate versions or constitute a v12.0.1 release.
Workflow inventory (34 definitions)
Durations are intentionally omitted because runner load, cache state, and the dependency graph make static estimates unreliable.
| Workflow | Trigger | Mode | Actual scope |
|---|---|---|---|
architecture.yml | main/v12/v13 push and PR, manual | Blocking | Compares Cargo’s publishable non-dev internal dependency graph with the reviewed crate-architecture-policy.json; unreviewed normal/build edges, removals, or optionality changes fail, while test-only dev-dependencies do not masquerade as production coupling. |
bench.yml | main push, weekly, manual | Automated evidence | Eight published groups backed by nine Criterion binaries, with non-blocking 20% regression alerts and gh-pages data consumed by the benchmark hub. Scheduled runs use the repository default branch. |
ci.yml | main/v12/v13 push and PR (Linux gate on PRs to main; full OS matrix with the full-matrix label), nightly full matrix, manual | Blocking plus observational report | Format, all-target/all-feature Clippy, seven-shard multi-OS tests including Cargo-aware doctests sourced from all 48 tutorials, four-way feature/threat partitions, the SQLite transactional outbox contract and Messaging concurrency suite, relational/polyglot live matrices, isolated strict-DB/feature boundaries, MSRV, the generated VM deployment built and started with Docker and rootless Podman (manual, nightly and deploy-changing pull requests), the first-hour documentation path run as written (manual, nightly and pull requests changing a marked page or the CLI), and a ready-PR/manual full-matrix SHA-bound per-crate quality scorecard artifact. A targeted manual OS/shard run is diagnostic and cannot emit the full scorecard. |
cli-artifacts.yml | reusable call from full/CLI manual Rust CI or admitted tag release | Blocking caller job; required release evidence | Builds and runs both CLI entry points on four explicit native targets; stages bounded executables and source/version/platform/digest inventories before attestation. CI artifacts are diagnostic; only the tag pipeline adds separate provenance and release assets. No installation occurs. |
codeql.yml | main/v12/v13 push, v12/v13 PR, weekly, manual | Blocking run | Rust CodeQL after an all-target/all-feature workspace check, plus JavaScript/TypeScript analysis. |
coverage.yml | main/v12/v13 push, v12/v13 PR, weekly, manual | Blocking plus observational job | LLVM LCOV generation with a pinned, zero-retry, bounded-concurrency nextest scheduler and retained JUnit inventory; a focused default-SQLite pass for ORM/Studio/Nexus/the facade; exact local 90% floors; and blocking OIDC-authenticated Codecov upload. Scheduled/manual branch instrumentation is non-blocking and uses the pinned verifier-only nightly. |
crypto-containment.yml | relevant main changes and PRs, weekly, manual | Blocking | Signed OTA and Vault tests, RustSec audit, and simulator-boundary checks; explicitly no PQC/HSM certification. Until 13.0.0-alpha.4 this was pqc-compliance.yml; the pqc and hsm modules it scans are deterministic simulator fixtures. |
dast-zap.yml | manual | Blocking generated targets plus informational showcase | Pins the ZAP image by digest, scans fresh release/migrated REST API, SaaS starter (public and signed-in) and LMS surfaces as blocking gates, scans the CDN-backed blog showcase informationally, and uploads separate reports plus application logs. |
documentation.yml | main/v12/v13 push and PR, weekly, manual | Blocking plus informational external scan | Builds the mdBook; validates landing/benchmark templates, project identity, the README workflow count, the inventory below and every workflow badge or link, local assets, pinned external chart scripts and all requested social links. Real Chromium checks desktop/390px/320px layout, keyboard/mobile navigation, clipboard success/denial, privacy disclosure, reduced motion, no-JS navigation, and absence of external landing requests/browser storage. This is a bounded browser contract, not WCAG certification. Also validates the 190-claim historical roadmap denominator and repository-local links. Scheduled/manual runs preserve an informational external-link report. |
e2e-smoke.yml | main/v12/v13 push and PR, manual | Blocking | Boots the release Blog application and checks HTTP, headers, CSRF form flow, SQLite persistence, and the persisted page parsed by real headless Chromium. |
fuzzing.yml | manual | Blocking release campaign or diagnostic | Release mode maintains all 42 targets through independently verified, at-most-seven-day input-equivalent evidence or fresh 5.5-hour jobs. A package’s inputs are the crates it reaches through path dependencies, files they include from elsewhere and a few global files; a change reruns the packages whose inputs it touches (all targets for global inputs, none for docs, changelog, CLI or examples), and a package whose inputs cannot be bounded falls back to the whole repository. Selected packages retain locked preflights, corpus caching and failure reproducers. force_full always requests a new full campaign; the current-commit boundary and independent tag-admission check remain mandatory. Lock drift is always rejected. The parser restarts its ASan process at most every 30 minutes while preserving the budget. Single-target diagnostic mode runs for five minutes and the evidence boundary marks it ineligible for release. |
iot-integration.yml | main/v12/v13 push and PR, manual | Blocking | Host IoT tests, signed OTA invariants, and one Cortex-M no-std build; no hardware claim. |
kani.yml | weekly (Thursday 01:47 UTC), manual | Blocking release scope; bounded evidence | Builds an immutable reviewed Kani snapshot with the verifier-only nightly-2026-08-01 compiler, while Rullst stays on stable Rust 1.99.0 with a Rust 1.96 MSRV. It verifies twenty named bounded harnesses in isolated jobs across ten supported packages. Proof failures fail their matrix jobs; the proc-macro-only crate remains outside Kani’s supported targets. |
machete.yml | main/v12/v13 push and PR, manual | Blocking | Unused dependency scan with configured exceptions. |
miri.yml | weekly (Wednesday 01:17 UTC), manual | Blocking release scope; bounded evidence | Pinned nightly-only Miri executes 15 named pure-Rust/default-feature scopes with randomized layouts without changing Rullst’s stable toolchain or MSRV. Native FFI/syscall/network paths, the umbrella re-export facade, and the Blog example are explicit boundaries; selected-scope failures fail the workflow. |
mutants.yml | manual | Informational | A source-bound inventory preflight followed by eighty lossless pinned cargo-mutants 27.1.0 shards with four concurrent jobs over that source’s all-feature workspace, each shard with a pinned Redis service for the RULLST_TEST_REDIS_URL contracts, one validated production-file diagnostic, one owned-server contract (mode=contract: a PostgreSQL or Redis wrapper’s adapter mutants against the server it owns, or the ORM crates against the rullst-orm SQLite suite), or policy-bound exact-SHA recovery of reviewed failed fragments. Successful immutable artifacts may be reused, but the content-addressed aggregate still requires every reviewed candidate exactly once and carries source/run/branch/tool/inventory provenance. Findings stay informational, while baseline/tool/invocation failures, missing artifacts, incomplete classification and reviewed-inventory drift fail the run. The completed v12 receipt is run 34761010296: 14,391/14,391 classified and a conservative 70.57% caught. |
mutants-pr.yml | pull requests to main that change Rust | Blocking by policy; not a required check | Lists the pinned cargo-mutants 27.1.0 all-feature mutants on the Rust lines the pull request changes, skipping the globs in .github/mutants-pr-exclude.txt and every CLI file not listed in .github/mutants-pr-cli.txt (listed CLI files run against the CLI’s library tests), and tests them in at most eight shards with a Redis service and the targeted-run timeouts and memory cap. A missed mutant fails the run; unviable and timed-out mutants do not. See Pull-request mutation gate. |
no_std-build.yml | main/v12/v13 push and PR, manual | Blocking | Builds rullst-iot for three bare-metal targets; this is compile evidence, not hardware execution. |
pages.yml | main push, manual | Deploy | Validates and deploys the current development landing page, local visual assets, mdBook and benchmark hub/dashboards to GitHub Pages while preserving history data fetched from gh-pages. |
pr-security-evidence.yml | pull requests | Automated evidence | Generates bounded CLI audit, compliance report, and CycloneDX SBOM artifacts; no certification claim. Until 13.0.0-alpha.4 this was ai-sentinel-pr.yml; it calls no AI service. |
proptest.yml | weekly, manual | Blocking run | Eight parallel release-mode workspace shards plus dedicated ORM and Connect property contracts with configured case counts. |
release.yml | exact-looking version tags | Release | Tag/changelog validation, exact-SHA admission of the full CI matrix (not repeated in the tag run), package-all, evidence bundle, checksums, GitHub build-provenance attestation, changelog-derived release notes, and dependency-order publication. |
sanitizers.yml | daily, manual | Blocking run | TSan and ASan library matrices on pinned nightly-2026-08-21; this verifier toolchain does not change Rullst’s stable compiler or MSRV. |
scorecards.yml | main push, weekly, manual | Automated evidence | OpenSSF Scorecard analysis and SARIF/artifact upload; not SLSA certification. |
semver.yml | main/v12/v13 push, PR, manual | Blocking | Fans out one job per machine-readable release-order entry and compares each supported library API with the latest stable, non-yanked crates.io release of the same major version (.github/check-semver.sh; selection tested by .github/test-semver-baseline-constraints.py). Prereleases may change the API, so a line without a stable release has no baseline: each 13.0 prerelease job reports that with a notice and compares nothing, and the 13.x comparison starts by itself once 13.0.0 is published. A tree whose version still equals that release is checked as the next minor release (breaking changes fail, deprecations pass); a bumped version is checked for the change it declares. The frozen v12 branch runs its own copy of the workflow and script. Never-published packages and proc-macro/binary API surfaces unsupported by cargo-semver-checks are reported explicitly. The registry comparison skips pull requests to main; the Public API snapshot job also runs on them and compares each library’s documented API with its listing in .github/public-api/. |
spellcheck.yml | main/v12/v13 push and PR, manual | Blocking | Repository typo scan. |
supply-chain.yml | main/v12/v13 push and PR, daily, manual | Blocking | Three jobs, which until 13.0.0-alpha.4 were cargo-deny.yml, audit.yml and security-audit.yml. Check Dependencies (Licenses, Vulnerabilities) runs Cargo Deny’s advisory, license, ban and source policy from deny.toml (a required check on main). Cargo Audit (production and fuzz locks) audits the production lock and all eleven fuzz-package locks with one advisory-database fetch. Advisory exception governance checks that the active advisory IDs in the ledger, deny.toml and every workflow’s --ignore/--audit-ignore flags agree, and that each ledger row has an owner and an unexpired date. No advisory is ignored today. The daily scheduled run uses its own concurrency group, so it never cancels the main push run that release admission requires. The v12 lock audit was removed at the 12.x end of life (16 October 2026); the frozen v12 branch keeps its own copies of the old workflows. Before Cargo Deny, Check Dependencies (Licenses, Vulnerabilities) runs .github/check-public-dependencies.py (tested by .github/test-check-public-dependencies.py), which keeps every public dependency on the 13.x line in .github/public-dependencies.json. |
trufflehog.yml | main/v12/v13 push and PR, weekly, manual | Blocking | Verified-secret scan over the configured Git history range. |
udeps.yml | weekly, manual | Informational | cargo-udeps signal on pinned nightly-2026-08-21; command failures are tolerated. |
unsafe-policy.yml | main/v12/v13 push and PR, manual | Blocking | Denies new production unsafe code and validates the reviewed exception allowlist. |
verus.yml | manual-only v13 candidate | Strict pilot, outside release admission | Verifies the source-linked age-policy method predicate with pinned Verus/Rust/Z3 and requires three postcondition-failing controls. Uploads fingerprints, tool identities and resource measurements; hosted acceptance/promotion pending. |
wasm-matrix.yml | main/v12/v13 push and PR, manual | Blocking | Compiles Core, the public rullst facade (without features, with its defaults and with the CLI-generated application feature set) and macros for wasm32-unknown-unknown and wasm32-wasip1. |
workflow-lint.yml | main/v12/v13 push and PR, manual | Blocking | Validates the shared fuzz inventory, then Actionlint checks workflow syntax, GitHub expressions and embedded shell using an immutable container digest. |
zero-panics.yml | main/v12/v13 push and PR, manual | Blocking | Panic-family Clippy lints plus generated-code regression checks for published runtime targets. |
Informational workflows reviewed for 13.0.0-alpha.4
Each informational workflow’s run history was read for 13.0.0-alpha.4, asking
whether a finding ever led to a commit and whether another workflow already
covers it. corpus-sync.yml was removed (see
Fuzzing and OSS-Fuzz); these two stay:
mutants.ymlstays: it measures the mutation campaigns (MAINTAINING.md section 6), admits CLI files to.github/mutants-pr-cli.txt, and its targeted runs are dispatched and acted on routinely.udeps.ymlstays: it is the only check of unused dev- and build-dependencies, becausecargo machetereads only[dependencies]. Its weekly report listed the same findings from September 2026; 13.0.0-alpha.4 removed the three unused ones (instaincargo-rullst,proptestinrullstandloominrullst-connect). The remainingrullst-ormandrullst-studioentries are test dependencies the all-feature build compiles out, so any other entry is a new finding.
Verification efficiency — v12 maintenance and v13
Priority: first, before the planned 12.1.0 updater and concentrated v13 product development. Compatible policy/tooling can serve both release lines after review; it does not require a new framework capability release. The draft/ready split, seven OS shards, Linux-only post-merge repetition, mutation recovery and targeted diagnostic modes described above already exist. Measurement and change-impact observation tooling now exists on v13. A narrowly scoped development-only site admission path is being validated below; affected-crate execution and general cross-commit release-evidence reuse are not enabled.
The ci.yml, documentation.yml and workflow-lint.yml development triggers
include v13, as do those of the other branch-filtered workflows except the
main-only benchmark, Pages, Scorecard and IoT cryptography workflows; these three jobs are not the whole release gate. The workflow-lint job retains a source/policy-bound
rullst.verification-plan.v1 observation. It uses committed Git objects and TOML
metadata without executing Cargo, build scripts, macros or project tests. All
normal/optional/target/build/dev dependency edges participate in its reverse
closure. Renames, deletions, symlinks, unknown paths, policy/lockfile changes,
critical crates, generators and potentially executable Markdown retain the full
recommendation. Missing history or unsupported graph shapes also fall back to
full. may_skip_checks is always false and no workflow consumes this report to
skip a job. Candidate package lists are not a complete test/feature matrix.
The separate admit-site-only.py / plan-ci-scope.sh path can avoid repeating
Rust CI only for a development main or transitional v13 push whose committed changes exclusively touch
docs/home_template.html, docs/site.css or docs/site.js. It requires the
immediately preceding commit to have a successful full Linux push CI in this
repository within 72 hours, on the same branch, with all 24 expected runtime jobs completed
successfully. Run/attempt/source/repository/branch identity and the entire job
inventory are checked. A skipped runtime job, unknown job, failure, missing
history, unavailable API, changed policy, symlink, mode change or stale receipt
retains full runtime checks. The source-equivalence check also prevents an
untested intermediate code change from being hidden by a later CSS-only push.
The helpers are loaded from the preceding committed source and executed with
isolated Python imports. A candidate cannot replace its own admission helper;
the shell’s error path and every runtime job guard also retain full execution
if scope planning fails. Admitted pushes still build the book and run static
site validation and real Chromium tests, plus the separate documentation and
workflow checks. They do not produce a quality scorecard or qualify as release
evidence. PRs, v12, manual matrices, fuzzing, mutation campaigns and release
admission are unchanged. The first implementation deliberately does not
chain presentation-only receipts or search arbitrarily old baselines; a missing
immediate full baseline incurs a normal run.
Hosted rollout evidence on September 15, 2026: full Linux push CI 35000185080
at 42dd0545 passed all 25 runtime jobs in 20m57s creation-to-completion.
The following presentation-only CI 35002518137
at 3229b762 passed scope admission and fresh site validation in 43s, with
runtime jobs skipped as intended. Separate documentation and workflow checks
also passed. These are two observed Rust CI elapsed times, not a universal
speedup, a sum of all workflow durations, or permission to reuse release evidence.
Documentation and Pages builds install the same pinned mdBook version from its prebuilt release instead of recompiling mdBook on each runner. Book, link and browser checks remain enabled. Dependency changes still require runtime CI.
Before workspace Clippy, check-fuzz-locks.sh resolves all eleven fuzz dependency
graphs with cargo metadata --locked and the campaign’s explicit Linux target.
It does not compile or execute fuzzers. This catches stale fuzz lockfiles after
workspace dependency updates without waiting for a long campaign build. Both
campaign preflights use the same full graph resolution; --no-deps is forbidden
because a real offline Cargo regression demonstrates that it accepts stale
path-dependency locks. Five tests cover that failure, the complete inventory,
early failure, argument rejection and CI wiring. Neither the forty-two-target
inventory nor campaign duration or release admission is reduced.
After formatting, check-package-reproducibility.sh packages rullst-macros
and rullst-orm-macros twice with --no-verify: once from the CI checkout and
once from a fresh worktree of the same commit at another path, with new file
times and umask 077, each into an empty target directory. Different SHA-256
digests fail the job. It compiles nothing; the release preflight still compares
every admitted archive. See reproducible crate archives.
The threat-model runner now validates all evidence rows before downloads or compilation and runs each unique exact test once, instead of first invoking Cargo again to list that target. The current 66 evidence mappings, 50 threat IDs and 65 unique tests therefore need one Cargo test invocation per unique test instead of two, excluding each shard’s retained locked fetch. Every selected test still runs in a separate all-feature process on its deterministic hash shard. Both a successful process exit and a bounded libtest log showing exactly one named, passed, non-ignored test are required. An ignored or missing test can no longer produce a false pass from libtest’s zero exit status.
The LMS threat mapping (ACADEMY-02; v13 retired the other Academy mappings
with the complete Academy scaffold) selects
security_tests::another_learners_lessons_are_refused in the LMS example
(rullst-lms-example, --lib). Since 13.0.0-alpha.4 the LMS starter is that
workspace example, so the exact test needs no nested generated build; the
workspace shard also runs it with the example’s other tests. The normal
foundation and product shards exercise the four reviewed generated
configurations: Blank, the hot-reload JSON API, hot-reload SaaS and SaaS’s
release build.
Before 13.0.0-alpha.4, the mapping materialized the generated LMS starter
(materialized_lms_executes_security_contracts). That narrowing addressed an observed bottleneck: in Rust CI 35007899011,
the old all-blueprint wrapper consumed 958.22 seconds inside threat shard 0,
whose complete job took 26m12s. That gate needed LMS evidence, not another
execution of the seven other project configurations. Hosted threat jobs now
use the same bounded two-job nested compiler setting as the normal generated
matrix. Removing warm listing calls alone saves overhead, not half the
compilation time. The complete optimized Linux run passed all 25 required
runtime jobs, plus documentation and workflow validation.
| Measurement | Before: 35011900532 | After: 35016405011 |
|---|---|---|
| Required runtime jobs successful | 25/25 | 25/25 |
| Threat-model shard 0 execution | 28m34s | 10m22s |
| Generated-project threat wrapper | 1,063.37 seconds | 227.75 seconds |
| Summed measured runner time | 196.68 minutes | 147.55 minutes |
| Whole Rust CI elapsed time | 43m43s | 61m12s |
These source revisions are 0b3588a4 and d0898516. Execution work decreased,
but some jobs waited up to 51m40s between creation and start, so the whole
workflow took longer. Waiting can include orchestration and runner availability.
Cache warmth and overlapping workflows differ; this is not a controlled
benchmark or a fixed speedup guarantee. Do not increase shard counts blindly:
runner contention can outweigh shorter individual jobs. Complete and validate
one feedback batch before pushing another when practical; no required check is
cancelled merely to improve the displayed elapsed time.
The timing reporter leaves skipped jobs visible but does not assign them an execution duration. GitHub can synthesize reversed timestamps for an unexecuted job. Malformed timestamps, contradictory executed steps and negative intervals in jobs that actually ran still fail. This observation-only correction does not change site admission or release-evidence rules. The compatible v12 backport must earn its own platform evidence; a v13 run is not a main acceptance receipt.
Six parser tests include a real compiled Rust harness; five runner tests check the full exact inventory, shard partition and failure paths. Four generated scheduling tests compile the actual selectors and wrappers with a recorded verifier, proving the four-case partition without building applications locally. That recorder is policy evidence only: real generated application tests and full CI remain required. These changes do not enable general fuzz-result reuse or relax release gates.
Single-target fuzz diagnostics now compile only the requested target in their package preflight, rather than every other target in that package. The exact target/package pair is checked again before Cargo; unknown, ambiguous or option-like targets fail before compilation. Release mode still compiles every target in all eleven packages before its forty-two 5.5-hour campaigns. Five scheduling tests cover all forty-two diagnostic selections, the complete package inventory, invalid inputs and compiler failure propagation. No sanitizer, instrumentation, corpus or campaign duration is weakened, and diagnostics remain ineligible as release evidence. Fuzzing and mutation campaigns remain manual, and Miri and Kani run weekly besides their dispatched admission runs; an ordinary push does not start any of them.
Read-only local commands (the planner inspects commits, not uncommitted files):
python3 .github/plan-verification.py --base main --head HEAD
python3 .github/test-plan-verification.py
python3 .github/test-report-ci-timings.py
python3 .github/test-admit-site-only.py
python3 .github/test-ci-scope.py
python3 .github/test-exact-rust-test.py
python3 .github/test-threat-model-runner.py
python3 .github/test-generated-evidence.py
python3 .github/test-fuzz-preflight.py
gh api --paginate --slurp \
'repos/Rullst/Rullst/actions/runs/34980693742/attempts/1/jobs?per_page=100' \
| python3 .github/report-ci-timings.py --top 10
Initial timing observation: Rust CI run 34980693742, attempt 1
reported head 792c1d554465c016abfdc223cbcaf7d94ec8c0c5 and 42 completed jobs.
The longest job was Windows/workspace at 30.05 minutes (27.83 in the combined
build/test step); the longest creation-to-start wait was 17.50 minutes.
Those waits can include orchestration, not just runner queues. The summed
386.72 runner-minutes are neither elapsed workflow time nor a billing estimate.
This one run is a baseline, not a measured speedup, cold/warm comparison or
evidence that compilation and test execution have been timed separately.
| Order | Improvement | Acceptance evidence |
|---|---|---|
| 1 | Measure the existing bottlenecks before changing scheduling | Record queue, restore, native compilation, Rust compilation and test durations separately, by OS and cold/warm cache. Compare equivalent inventories and runner conditions. Do not promise a fixed speedup. |
| 2 | Classify documentation, site, runtime, generator, dependency and verification-policy changes | Run the relevant book/link/browser/ledger tests for presentation changes. Markdown containing executable examples still needs its doctests. Unknown paths, renames, deletions and classifier failures fall back to broad verification; .github/** is never automatically a documentation-only exemption. |
| 3 | Select affected crates and reverse dependencies for development feedback | Unit-test the selection policy against runtime, macros, manifests, lockfiles, build scripts, generated templates, shared fixtures and security controls. Prove no required test or feature combination disappears before enabling skips. Keep full/manual and broad scheduled/release modes. |
| 4 | Reduce duplicate compilation and balance shards using measurements | Isolate trusted and untrusted caches; key compatible artifacts by toolchain, target, profile, features and dependency inputs. Preserve every case and assertion, report cache misses honestly and measure total runner time as well as elapsed time. |
| 5 | Reuse expensive verification only under an explicit admission policy | Bind receipts to source inputs, dependency locks, tool versions, flags, inventory, artifacts and policy. Failed, partial, diagnostic, expired or mismatched evidence cannot become a release pass. Keep conservative reruns for security-relevant or uncertain impact. |
The invariants remain unchanged: repository and library coverage floors stay at 90%; negative authorization and tenant-isolation tests remain enforced; no timeout/crash becomes a tolerated success merely to shorten a run. A reduction in redundant work must not be presented as stronger security proof.
Until a reviewed implementation changes the relevant contract, the current
AGENTS.md local baseline and exact-source release admission rules still apply.
Update automation, policy tests and this document together; a roadmap paragraph
alone must never authorize skipping a required check. Carry compatible fixes
from v12 to v13, without importing unrelated next-major features into v12.
Preserved next-generation roadmap
These ideas remain valuable, but are not current guarantees:
| Idea | Status and recommendation |
|---|---|
| Verus proofs for selected production contracts | Locally verified v13 candidate; hosted acceptance pending. verus.yml is manual-only, with a pinned archive/compiler/solver, syntax-linked AgePolicy::permits projection, one verified predicate, three mandatory postcondition-failing controls and source/resource receipts. Normal CLI CI checks the source linkage without installing Verus. The pilot remains outside release admission; Auth, Capital, age evidence/time/storage and promotion remain separate work. See the measured scope and assumptions. No additional v12.1 gate. |
| Loom and Shuttle concurrency exploration | Not implemented — worth implementing for the small shared-state primitives that have explicit concurrency invariants. Do not apply them indiscriminately to the whole workspace. |
cargo-vet dependency review | Not implemented — worth implementing once review ownership and audit criteria are defined; an empty policy file would add ceremony without assurance. |
cargo-careful and zero-allocation assertions | Not implemented — worth targeted experiments. Allocation claims need stable benchmarks and explicit hot paths before becoming gates. |
| PGO and BOLT | Not implemented — defer until production profiles exist. Fixed throughput-gain percentages must not be promised in advance. |
Chaos testing with fail-rs | Not implemented — worth implementing around queues, database retries, and provider timeouts after deterministic failure contracts exist. |
| AFL.rs/honggfuzz differential fuzzing | Not implemented — valuable after the 42 libFuzzer targets have healthy corpora and triage ownership. |
| Cross-platform CI acceleration | Partial — first engineering priority, also applicable to compatible v12 maintenance. The inherited v12 baseline has eight cargo test shards per OS and isolated fixture targets. Coverage separately uses nextest; the ordinary CI runner has not migrated to it. Preserve workspace, feature, generated-project, outbox, doctest and live-provider contracts while measuring further scheduling/cache improvements. Speed alone must never reduce assertions or supported-platform evidence. |
| Differential database testing | Not implemented — high-value v13 work. Run equivalent generated ORM operations against the supported relational backends and compare normalized results, errors and transaction behavior; keep provider-specific semantics explicit instead of forcing false equivalence. |
| Cross-browser and accessibility testing | Not implemented — high-value v13 work. Exercise generated applications with Playwright across Chromium, Firefox and WebKit, add keyboard and automated accessibility checks, and retain traces/screenshots for failures. This would complement, not replace, ZAP and server-level integration tests. |
| Mobile physical-device farms | Not implemented — requires external infrastructure. Add Android and iOS device-farm execution, lifecycle/network interruption scenarios and signed-package evidence when accounts and secrets are governed. Simulator and compile checks must not be presented as physical-device or store-acceptance proof. |
| Desktop GUI, installer and signing matrix | Not implemented — requires platform signing operations. Exercise real WebView sessions and packaged installers on supported Linux, Windows and macOS versions, then validate signatures/notarization without exposing signing credentials to untrusted pull requests. |
| Live provider sandbox conformance | Not implemented — requires governed provider accounts. Add opt-in acceptance suites for payment, mail, OAuth, AI, search and storage providers, recording provider/version/region while preserving deterministic offline fixtures as the contribution baseline. |
| Independent security review and penetration test | External evidence — strongly recommended before broad production assurances. Define scope, remediation ownership and retest criteria; repository automation cannot award this evidence to itself. |
| Sigstore Cosign signing | Not implemented. Consider it for separately distributed binaries/containers; current .crate provenance and checksums should remain the immediate priority. |
| Absolute “100% pure Rustls” mandate | Not established and not recommended as a marketing absolute. Enforce an audited TLS dependency policy based on supported platforms and threat model instead. |
| Complete upstream OSS-Fuzz integration | Partial draft — worth finishing. Validate every intended target with helper.py build_fuzzers and check_build, then submit upstream; do not imply acceptance before merge. |
Cross-platform CI acceleration acceptance plan
The September 6 v12 candidate measurements record the pre-optimization baseline:
the all-feature workspace and follow-up contracts took approximately 70 minutes
on Linux, 59 minutes on macOS, and 107 minutes on Windows. Most time was spent
in the clean cargo test --workspace --all-features build. At that checkpoint,
only Cargo registry data was cached. Workspace target/ caching was disabled
after CLI integration tests reused and cleaned nested target paths while the post-job
cache collector traversed them, producing false missing-file annotations and
multi-gigabyte uploads.
This is historical context, not the current configuration or expected duration.
The stable v12 baseline now uses the sharded cargo test system documented
above; pinned nextest currently schedules the coverage pass only. New
experiments must measure that inherited configuration, not claim a
speedup against obsolete commands or omit checks that moved into other jobs.
An acceleration change is acceptable only when all of these conditions hold:
- Give repository compilation and every generated-project family distinct, explicit target roots. A generated fixture may clean only its own disposable root and must never mutate a cached workspace target.
- Record cold and warm wall time, cache size/hit data, discovered test counts, failures and doctest results on Linux, macOS and Windows. Compare equivalent commit content; queue time is reported separately from execution time.
- Preserve the all-feature workspace test inventory, portable transactional outbox contract and Linux live-provider matrices until an alternative proves identical coverage. Package sharding must not weaken Cargo feature unification.
- Evaluate a pinned compiled-artifact cache and
sccacheindependently before composing them. Keys must include OS, compiler, lockfile and relevant profile inputs; caches are performance hints, never release artifacts or evidence that tests ran. Bound storage and prevent untrusted pull requests from replacing a protected default-branch cache. - Evaluate nextest for ordinary CI (or change its coverage configuration) only if tests,
ignored-test policy, retries, process cleanup and failure reporting remain
equivalent. Run Cargo doctests
separately because nextest does not replace them. Keep plain
cargo testas a documented recovery path. - Promote the experiment to blocking CI only after repeated green cold and warm runs on every supported OS show a material wall-time reduction without new flakes, lost tests, hidden failures or multi-gigabyte cache churn. Retain the previous workflow as a quick rollback during the observation window.
For development-only presentation skips, comparing with the immediately preceding commit is insufficient: that commit might have an unfinished or failed runtime run which a new push cancels. Admission must bind a completed successful runtime inventory to its exact baseline commit, compare that baseline with the candidate, and reject incomplete, stale, diagnostic or presentation-only receipts. Manual/release runs remain full. Do not infer receipt validity from a green workflow badge, file extensions alone or the observation report.
This work optimizes feedback latency, not the evidence boundary. Any v12 maintenance change must preserve the immutable v12.0.0 artifacts and carry its own applicable verification. Further scheduling/cache changes need comparable cross-platform A/B receipts before their claimed benefit is accepted.
The goal of this roadmap is stronger, reproducible evidence—not a larger number of badges or absolute claims that no finite test suite can establish.
Reproducible Crate Archives
Rullst claims one specific kind of reproducibility: for the same commit and the
toolchain pinned in that commit’s rust-toolchain.toml, cargo package
produces bit-for-bit identical .crate archives. The checkout path, file
modification times, file ownership and the user’s umask do not change the
bytes. This page states the evidence, how to check it yourself and what is not
claimed.
Why the archives are stable
Cargo writes the tar archive in a deterministic mode. In the archives Rullst
publishes, every entry has the same fixed timestamp (2006-07-23 22:21 UTC),
user and group 0, no user or group names, and normalized permissions: a
checkout made under umask 077 still produces the same bytes. The gzip header
carries no timestamp. .cargo_vcs_info.json records the commit
and the crate’s path inside the repository, not an absolute path. The
embedded Cargo.lock comes from the workspace lockfile through --locked.
Evidence
| Check | When it runs | What it compares |
|---|---|---|
check-package-reproducibility.sh in the Code Quality & Format job of ci.yml | Every pull request and push that runs Rust CI | rullst-macros and rullst-orm-macros, packaged once from the CI checkout and once from a fresh git worktree of the same commit at another path, with new file times and umask 077. Each run uses its own empty target directory, and the SHA-256 digests must match. |
Reproduce publication inputs without registry identity in release.yml | Every release tag, before publication | Every crate in release-order.json is repackaged from the tagged checkout on a separate runner and compared byte-for-byte (cmp) with the archives that were verified and attested. Any difference stops the release. For example, v12.2.0 run 37128793097 passed this job. |
| Independent local rebuild, 9 October 2026 | One-off check | A fresh clone of tag v12.2.0 (565e222a3249f788a0eb560b7ecd6a2ce379ad47), packaged with Rust 1.98.1 on a developer workstation. It reproduced rullst-macros-12.2.0.crate with SHA-256 23f73c494af1e7d37084e7bce8bcef77bf5638f0f822d79c3334a2353e807bf5, the same digest as the GitHub release asset, its checksums.txt and the crates.io checksum. |
The release workflow’s verification job packages with full build verification
and the preflight job packages with --no-verify. Their archives are compared
byte-for-byte, so build verification does not change the archive.
Reproduce a published crate yourself
Pick a published version and its tag. The tag’s rust-toolchain.toml selects
the pinned toolchain automatically through rustup.
git clone https://github.com/Rullst/Rullst.git
cd Rullst
git checkout v12.2.0
target_dir="$(mktemp -d)"
CARGO_TARGET_DIR="$target_dir" cargo package -p rullst-macros \
--all-features --locked --no-verify
sha256sum "$target_dir/package/rullst-macros-12.2.0.crate"
curl --silent --user-agent "rullst-reproduction-check" \
https://crates.io/api/v1/crates/rullst-macros/12.2.0 | jq -r .version.checksum
The two digests must be equal. They must also match the line for the same
file in the release’s checksums.txt. To check the signature on that release
as well, see Verifying a release.
Limits
- Only
.cratesource archives are claimed. The nativerullstandcargo-rullstexecutables attached to GitHub releases are built on GitHub-hosted runners and attested, but they are not claimed to be bit-for-bit reproducible. They embed platform, linker and toolchain details, and no job rebuilds and compares them. - The same toolchain is required. A different Cargo version may normalize manifests or lay out archives differently. Use the toolchain pinned by the tag.
- The same clean commit is required. Packaging uncommitted changes with
--allow-dirtyrecords a dirty state and different content. - The pull-request check is bounded. It covers two small crates to keep the cost low. All admitted crates are compared once per release tag.
- Release evidence files are not claimed reproducible. The SBOM, audit results, metadata and release context are generated at release time. The context records the workflow run, and the audit reflects the advisory database of that moment. They are attested, not reproduced.
rullst-orm
Important
This page describes the v13 prerelease source, and its dependency examples pin the published
13.0.0-alpha.4exactly: Cargo never selects a prerelease on its own, and a plain"13.0.0-alpha.4"would also accept later alphas (see the v13 migration guide). The v12 line (rullst-orm@12) reached end of life on 16 October 2026; see the release record. Use a path dependency only for checkout-local review.
🚀 Visit the Official Website & Documentation Hub 🚀
Built on top of sqlx and procedural macros, Rullst ORM brings the delightful, fluent syntax of Active Record frameworks directly to the high-performance Rust ecosystem.
🛡️ Security Engineering
Rullst ORM uses SQLx bindings, validated identifiers, typed errors, and layered CI checks. Workflow badges are scoped test results, not a guarantee for an application or deployment.
🚀 Why Rullst ORM?
Rullst ORM generates Active Record operations and a fluent query builder from
#[derive(Orm)]. SQLx remains available for queries that do not fit the
generated API. Both work with SQLite, PostgreSQL and MySQL/MariaDB; Turso/libSQL
and the side stores have their own APIs, as
Database Support lists.
Key Features:
- Generated CRUD: Insert, update, delete, restore, and find operations for
supported model shapes.
restore()andforce_delete()use the same savepoint, hook/observer, audit and post-commit cache/event/Scout pipeline asdelete()/save(); see Active Record CRUD. - Fluent Query Builder: Chain methods such as
.where_eq(),.limit(), and.order_by(); values are bound and structural identifiers are validated. - Relationships and eager loading:
has_many,has_one,belongs_to, and polymorphic relationship helpers, with explicit eager-load methods. - Opt-in tenant scope:
#[orm(tenant_column = "account_id")]adds the configured task-local tenant to generated model queries. Applications must establish the tenant context at their authenticated boundary. - Actor-bound audit revisions:
#[orm(auditable)]requires a validated user/service/systemAuditContext; the active tenant and optional correlation ID are recorded with recursively redacted bounded changes. Generated instance saves/deletes/restores/force-deletes and their audit entry share a savepoint and fail together. Eligible v2 updates expose guarded revision restoration, which rejects stale, cross-tenant, redacted, malformed, legacy, create/delete, and oversized revisions and records a compensating audit entry. The host still derives authenticated principal/tenant authority, while bulk per-row history and durable export remain explicit. See Auditable Revisions. - Field privacy:
#[orm(encrypted)]transparently encrypts supportedStringfields with a versioned AES-256-GCM envelope. Randomized ciphertext cannot be filtered or sorted; use a separate keyed blind index where needed. Encrypted and#[orm(masked)]values appear as"***"in generatedto_json(), audit rows and committed events, are omitted from Scout documents, and stay encrypted insave_to_redishashes.SecretStringfields are redacted the same way, andSecretStringserializes as an encrypted envelope rather than plaintext.RULLST_ENCRYPTION_KEY(and its_KEY_ID/_KEYRINGcompanions) is read from the process environment, then from./.env; see field encryption. - Native relational enums:
#[derive(Enum)]owns one closed label mapping for SQLx, Serde and ORM values.Blueprint::native_enumemits a named, drift-checked PostgreSQL type withstrict-postgres, inline MySQL/MariaDBENUM, or a SQLiteTEXT CHECKconstraint. - Scout hooks and providers:
#[orm(searchable)]calls a configuredSearchEngineafter generated writes/deletes.scout-httpsupplies bounded Meilisearch, Elasticsearch and Algolia adapters; the generated effect is process-local unless the application composes the transactional outbox. See Scout Search Providers. - Typed pgvector queries:
pgvectorre-exportsVectorwith SQLx support; vector/distance values in L2, cosine and inner-product helpers are bound, not interpolated. The strict PostgreSQL matrix creates the extension and runs a typed live lifecycle. See RAG Systems & Vector Search. - Generated embeddings:
aiimpliespgvectorand addssave_with_embedding(&rullst_ai::AiClient)to models with an#[orm(embedding_for = "...")]field; the application must also depend onrullst-ai. - Bounded Qdrant vectors:
qdrantkeeps specialized dense-cosine collection/upsert/delete/query semantics separate from SQL Active Record, with resource/transport bounds, deterministic fallback, authenticated protocol fixtures and a pinned live lifecycle. - Native Redis structures:
redisadds an immutable namespace and bounded Hash, Set and Sorted Set operations in addition to.remember; remote endpoints require TLS and live evidence covers isolation and native commands. - Portable document recovery: MongoDB, SurrealDB and the deterministic store expose identifier-preserving inventory. An application-operated, AES-256-GCM snapshot binds application/collection scope, compares two bounded source observations, resumes only into an exact destination subset and verifies the final inventory. Writers, schema provisioning, key custody and durable backup storage remain explicit operator responsibilities. See Polyglot Persistence.
- Structured telemetry: generated/raw query and stream spans expose only static model/table/operation metadata, managed transactions record bounded outcomes, and Rullst-created pools emit checkout timing. Core’s opt-in OpenTelemetry layer can export the standard tracing signals; subscriber, sampling, collector and separately configured SQLx logs remain host policy.
- Comparative SQLite evidence: a lockfile-pinned Criterion harness gives Rullst, Diesel and SeaORM one typed connection, the same indexed schema, 100-row seed, SQLite policy and five logical operations. The CI history is scoped comparison evidence; it does not claim universal or negligible overhead, networked-database throughput or complete-application performance.
- Durable opt-in outbox:
Outbox::enqueuecommits a stream-scoped, idempotent event with relational domain state. Exact lease tokens, bounded retry and dead-letter are shared by SQLite, PostgreSQL, MySQL and MariaDB, and streams and event keys are case-sensitive on all of them. Delivery is at least once, so the application dispatcher and consumer remain idempotent; generated observers are not silently converted into events. A nestedOrm::transactionjoins the active transaction through a savepoint, so a helper that enqueues inside its own transaction stays atomic with its caller. Concurrent sibling nested transactions take turns on the shared connection, and a savepoint left open makes the enclosing transaction roll back instead of committing. See the transactional outbox tutorial. - Database-first introspection:
cargo rullst generate:modelsreads SQLite, PostgreSQL, or MySQL metadata using bound schema/table parameters, normalizes table module identifiers, and rejects unsafe SQL identifiers, collisions, or columns requiring unsupported ORM remapping before writing files. - Additive migration generation:
make:migration:autocompares supported model definitions and emits a migration for review. - Cascading soft deletes: Opt-in relationship metadata can cascade through
generated delete methods; transaction-aware variants use the supplied
transaction. The related model must also use soft deletes; a cascade into a
model without them fails to compile instead of hard-deleting its rows. The
children are selected within the tenant scope but outside the related
model’s
global_scope. - Transactional partial updates (v13 candidate):
.update_partial()merges selected values into a fresh row and performs its full save lifecycle, with explicit transaction support. Use it instead ofsave(), which writes the whole row, when requests may edit the same row at the same time; see concurrent updates and counters and the contract and migration. - Model policies:
#[orm(policy = "MyPolicy")]invokes the configured policy on generated create/update/delete/restore operations. - Strict lazy-loading prevention: the global toggle makes generated lazy relationship methods return a validation error instead of performing the query.
- Explicit Capability Boundaries: Unsupported replication paths fail closed instead of reporting simulated success.
🛠️ Quick Start
Installation
Install the v13 prerelease with an exact pin:
cargo add rullst-orm@=13.0.0-alpha.4 -F strict-sqlite
cargo add tokio -F full
The code that #[derive(Orm)] generates needs nothing else: since
13.0.0-alpha.4 it reaches SQLx, tracing and tokio through rullst_orm,
and it implements SQLx’s FromRow for the model itself, so do not also
derive FromRow (two implementations do not compile, E0119). The build
therefore compiles no SQLx macro crate. tokio runs the example’s
#[tokio::main].
rullst-orm does not enable SQLx’s macros or derive features. To use
sqlx::query! or #[derive(sqlx::FromRow)] on your own structs, enable
them in your manifest, for example
sqlx = { version = "0.9", default-features = false, features = ["derive"] }
(macros for query!). This builds sqlx-macros and, for the
compile-time macros, a second copy of the SQLx driver for the build host. A
model whose FromRow you write yourself, by hand or with that derive,
declares #[orm(custom_from_row)] so #[derive(Orm)] does not generate one.
Select the driver of your database: strict-sqlite, strict-postgres or
strict-mysql, or drivers-all for all three on SQLx’s dynamic Any pool.
Since 13.0.0-alpha.3 the crate enables no driver by default; without one,
Orm::init fails at startup with an error that names the feature to enable.
Zero-to-Hero Example
use rullst_orm::schema::Schema;
use rullst_orm::Orm;
// 1. Just add the Orm macro to your struct!
#[derive(Debug, Clone, Orm)]
pub struct User {
pub id: i32, // ID = 0 means it hasn't been saved yet
pub name: String,
pub email: String,
#[orm(hidden)] // Excluded from generated to_json(), audit, event and search projections
pub password: String,
}
#[tokio::main]
async fn main() -> Result<(), rullst_orm::Error> {
// 2. Initialize the connection pool (SQLite, Postgres, MySQL/MariaDB)
Orm::init("sqlite::memory:").await?;
// 3. Create the `users` table (a migration does this in an application)
Schema::create("users", |table| {
table.id();
table.string("name");
table.string("email");
table.string("password");
})
.await?;
// 4. Create a new user through the generated Active Record API
let mut user = User {
id: 0,
name: "Alice".to_string(),
email: "[email protected]".to_string(),
password: "secret_password".to_string(),
};
user.save().await?; // Runs INSERT and hydrates the generated ID.
// 5. Fluent Queries
let active_users = User::query()
.where_like("email", "%@example.com")
.order_by_desc("id")
.limit(10)
.get()
.await?;
println!("Found {} user(s)", active_users.len());
Ok(())
}
Concurrent updates and counters
By default, save() on a loaded model writes every persisted column of that
handle, so the last writer wins: when two requests load the same row, change
different fields and save, the second save() silently reverts the first
one’s change. An insert also sends every field, so database column defaults
(such as DEFAULT CURRENT_TIMESTAMP) do not apply to model fields, except to
fields marked #[orm(db_default)].
Dirty tracking (v13). Declare one #[sqlx(skip)] field of type
RowSnapshot, and save() writes only the columns that this handle changed:
#![allow(unused)]
fn main() {
use rullst_orm::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "profiles")]
pub struct Profile {
pub id: i32,
pub name: String,
pub bio: String,
#[sqlx(skip)]
pub snapshot: RowSnapshot,
}
async fn rename(id: i32, name: String) -> Result<(), rullst_orm::Error> {
let mut profile = Profile::find(id)
.await?
.ok_or(rullst_orm::Error::RecordNotFound)?;
profile.name = name;
// `profile.dirty_columns()` is now `["name"]`.
profile.save().await // UPDATE profiles SET name = ? WHERE id = ?
}
}
- Records read through the ORM (
find,first,get,all,paginate, relations, streams, the query cache and the rowupdate_partial()reads) remember a 64-bit fingerprint of every column thatsave()updates: 16 bytes per record plus 8 per column, never the values.save()compares the current values with them:#[orm(encrypted)]andSecretStringfields by plaintext, so an unchanged secret is not encrypted again, JSON fields (#[sqlx(json)],Json<T>,serde_json::Value) by their canonical document, and other fields by their Serde serialization. - The
UPDATEsets only the changed columns, keeps the tenant predicate and returns the stored row (PostgreSQL and SQLite; MySQL/MariaDB read it again in the same transaction). The columns it did not write,#[orm(db_default)]columns and the soft-delete marker take their stored values, so the handle, the audit diff, model events and the search document describe the row as stored. A row deleted meanwhile still fails withRecordNotFound. - When nothing changed,
save()runs noUPDATE, noupdating,updatedorsavedobservers, noafter_savehook, audit entry or post-commit event. Thebefore_savehook and thesavingobservers run first and may change fields; a model with neither, and without a policy, does not even open a transaction. - A record built in code holds
RowSnapshot::default():save()inserts it, or, with a non-zeroid, writes every column.mark_all_dirty()makes a loaded record do the same, for code that relied on overwriting the row.is_dirty()tells whethersave()has anything to write. - A failed
save()keeps its changes pending. When a caller-owned transaction rolls back after a successfulsave(), reload the record or callmark_all_dirty(). - A
before_savehook that stampsupdated_aton every call makes every save write; stamp it only whenself.is_dirty(). - Mark the field
#[serde(skip)]when the model derives Serde traits. Any two snapshots compare equal and hash alike, so a derivedPartialEqorHashlooks at the columns only. Since 13.0.0-alpha.3 every SQLx model that the CLI writes declares the field:make:model(andmake:resource),generate:models(with#[serde(skip)]; a table with its ownsnapshotcolumn keeps full-row saves),auth,make:billing,make:chat-sessionand the models of everycargo rullst newstarter (Blank, API and SaaS) and of the LMS, ERP and Portfolio examples. Their struct literals setsnapshot: RowSnapshot::default(). Turso models have no dirty tracking. Projects generated earlier keep their models until you add the field.
Dirty tracking merges edits of different columns. Two writers of the same column still race, and the last one wins:
- To change some fields of a row that other requests may edit, use
update_partial(). It reads the current row in a transaction (withSELECT ... FOR UPDATEon PostgreSQL and MySQL/MariaDB), sets only the fields you chose and saves through the normal policy, hooks, audit and cache lifecycle. On SQLite a contendedupdate_partial()can fail with a database error instead of waiting; retry it. It merges fields, but it does not detect two editors changing the same field: that needs a revision column checked by the application. - For a counter, run one atomic statement with
Orm::raw. Its values are bound and it joins the activeOrm::transaction, but it skips the model’s tenant scope, policy, hooks, audit and cache invalidation: add the tenant condition yourself. Raw SQL uses the driver’s own markers,$1,$2on PostgreSQL and?on SQLite and MySQL/MariaDB.
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(table = "students")]
pub struct Student {
pub id: i32,
pub bio: String,
pub xp: i32,
}
/// Changes only `bio`: an XP award committed meanwhile is kept.
async fn edit_bio(student: &mut Student, bio: String) -> Result<(), rullst_orm::Error> {
student.update_partial().bio(bio).save().await
}
/// Adds XP in one statement, so concurrent awards add up.
async fn award_xp(student_id: i32, points: i32) -> Result<(), rullst_orm::Error> {
let sql = if Orm::driver()? == "postgres" {
"UPDATE students SET xp = xp + $1 WHERE id = $2"
} else {
"UPDATE students SET xp = xp + ? WHERE id = ?"
};
let changed = Orm::raw(sql).bind(points).bind(student_id).execute().await?;
if changed == 0 {
return Err(rullst_orm::Error::RecordNotFound);
}
Ok(())
}
}
Database errors
A unique or primary-key violation returns Error::UniqueViolation, and a
foreign-key violation (a missing referenced row, or deleting a row that is
still referenced) returns Error::ForeignKeyViolation. Generated writes, query
builders and Orm::raw all map them; every other database failure stays
Error::DatabaseError(String). constraint holds the name PostgreSQL reports
and is None on SQLite and MySQL/MariaDB. The message of these two errors
names only the constraint, because the database’s own text can quote the
rejected value (MySQL reports Duplicate entry '[email protected]').
#![allow(unused)]
fn main() {
use rullst_orm::{Error, Orm};
#[derive(Debug, Clone, Orm)]
#[orm(table = "members")]
pub struct Member {
pub id: i32,
pub email: String,
}
/// `Ok(None)` when the email is already registered.
async fn register(email: String) -> Result<Option<Member>, Error> {
let mut member = Member { id: 0, email };
match member.save().await {
Ok(()) => Ok(Some(member)),
Err(Error::UniqueViolation { .. }) => Ok(None),
Err(error) => Err(error),
}
}
}
Orm::transaction returns its closure’s error unchanged after the rollback,
so a violation inside it keeps its variant too. The closure may return the
application’s own error type when it implements From<rullst_orm::Error> and
Display; failures of the transaction itself, such as a commit rejected by a
deferred constraint, arrive through that conversion.
Query values
Filters such as where_eq and where_in, Orm::raw(..).bind(..) and
with_tenant take any value that converts into RullstValue: String/&str,
i32, i64, u32, f64, bool, the chrono types DateTime<Utc>,
NaiveDateTime, NaiveDate and NaiveTime (see
date and time columns), a #[derive(Enum)] type
(see native database enums) and Option of those. None binds SQL
NULL, typed as text: on PostgreSQL write CAST($1 AS BIGINT) to store it in
a column of another type. A comparison with NULL never matches, so filter
missing values with where_null. RullstValue is #[non_exhaustive]; give a
match on it a wildcard arm.
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(table = "progress")]
pub struct Progress {
pub id: i32,
pub lesson_id: Option<i64>,
pub seconds: i64,
}
async fn for_lesson(lesson_id: Option<i64>) -> Result<Vec<Progress>, rullst_orm::Error> {
let query = Progress::query();
match lesson_id {
Some(lesson_id) => query.where_eq("lesson_id", lesson_id).get().await,
None => query.where_null("lesson_id").get().await,
}
}
}
Query row cap
Generated builders start with a global row cap (Orm::set_max_query_limit,
1,000 by default; 0 disables it). limit() clamps to that cap and
unsafe_unlimited() removes it for one explicit query. paginate(page, per_page) clamps per_page to the same cap, because the value often comes
from request input; PaginationResult::per_page and last_page report the
effective page size. count() and the paginate() total count the rows the
query returns: a distinct() or group_by() query is counted as a derived
table, so the total is the number of distinct rows or groups.
Eager loading runs one related-model query for all parents of a batch and
never assigns relations from a result truncated by that cap: when the related
rows exceed it, get() fails with a Validation error naming the relation.
Load fewer parents per query, raise the cap, or choose explicitly with
with_<relation>_constrained(...): an explicit smaller limit(n) there applies
to the whole batch, and unsafe_unlimited() loads every related row.
Parents that share a related row or group all receive it: every child of one
belongs_to parent, parents whose non-unique local_key matches the same
has_many/has_one rows, and duplicated parent rows. The shared value is
cloned for all but the last such parent, so a related model without Clone
loads normally until a row must be shared, and then get() fails with a
Validation error instead of leaving a parent without its relation.
A relation key may be a nullable foreign key (author_id: Option<i32>) on
either model. A None key matches no row, like SQL NULL: the lazy loader
returns None or an empty list without a query, an eager load gives such a
parent None (or Some(vec![]) for a to-many relation), and a related row
whose key is None belongs to no parent.
Native database enums
Generated applications should select a strict primary feature. PostgreSQL
native enums specifically require strict-postgres, because SQLx’s dynamic
Any driver cannot decode custom PostgreSQL types:
rullst-orm = { version = "=13.0.0-alpha.4", features = ["strict-postgres"] }
Derive one label contract and use it in schema code:
#![allow(unused)]
fn main() {
use rullst_orm::schema::{Blueprint, Schema};
use rullst_orm::{Enum, Orm};
#[derive(Enum, Debug, Clone, Copy, PartialEq, Eq)]
#[rullst_enum(type_name = "account_status", rename_all = "snake_case")]
enum AccountStatus {
AwaitingReview,
Active,
}
async fn create_schema() -> Result<(), rullst_orm::Error> {
Orm::init("postgres://user:password@localhost/application").await?;
Schema::create("accounts", |table: &mut Blueprint| {
table.id();
table.native_enum::<AccountStatus>("status").not_null();
}).await?;
Ok(())
}
}
The derive accepts 1–64 unique labels of at most 63 bytes using ASCII letters,
digits, spaces, underscores or hyphens. An existing PostgreSQL type must have
the exact same ordered labels or schema creation fails. MySQL/MariaDB store the
labels in the table’s inline ENUM; SQLite enforces them through TEXT CHECK.
Adding, removing or reordering labels is an explicit reviewed migration. Drop
every dependent table before calling Schema::drop_native_enum::<T>() on
PostgreSQL; the method is a validated no-op on the other backends. The enum
type creation, its label check and drop_native_enum use the active
Orm::transaction or test sandbox like the table DDL, so they roll back with it
and the type can be dropped right after its tables in the same transaction.
Builder filters on a model field whose type derives Enum (or
Option<...> of it) work on every backend: on PostgreSQL the comparison,
IN and BETWEEN markers of that column become CAST(? AS "<type_name>"),
because a text parameter has no operator against a named enum type. This
covers where_eq, where_in, the generated where_<column> helpers and
their or_/not_ variants; where_like and raw SQL are unchanged.
Constraints, indexes and foreign keys
Schema::create declares unique constraints, indexes, foreign keys and CHECK
constraints next to the columns. Every name is validated with the identifier
grammar before it reaches DDL:
#![allow(unused)]
fn main() {
use rullst_orm::schema::{Blueprint, ColumnDefault, ForeignKeyAction, Schema};
async fn create_schema() -> Result<(), rullst_orm::Error> {
Schema::create("enrollments", |table: &mut Blueprint| {
table.id();
// An `INTEGER NOT NULL` column referencing `users(id)`.
table
.foreign_id("user_id", "users")
.on_delete(ForeignKeyAction::Cascade);
table.integer("course_id").not_null();
table
.foreign("course_id")
.references("courses", "id")
.on_delete(ForeignKeyAction::Restrict);
// MySQL/MariaDB cannot index a TEXT column: give indexed strings a length.
table.string("code").not_null().unique().col_type = "VARCHAR(64)".to_string();
table.integer("progress").not_null().default(ColumnDefault::Integer(0));
table.unique(&["user_id", "course_id"]);
table.index(&["course_id", "progress"]).name("enrollments_course_progress");
table.check("progress BETWEEN 0 AND 100");
table.timestamps();
}).await?;
Ok(())
}
}
Column::unique()adds a column-levelUNIQUE;unique(&[...])a table constraint named<table>_<columns>_uniqueunless.name(...)sets one.index(&[...])andunique_index(&[...])areCREATE [UNIQUE] INDEX IF NOT EXISTS <table>_<columns>_index|_uniquestatements afterCREATE TABLEon PostgreSQL and SQLite, andINDEXclauses ofCREATE TABLEon MySQL/MariaDB (which has noIF NOT EXISTSfor indexes). Names are at most 63 bytes, so PostgreSQL never truncates them; a longer default name fails and asks for.name(...).foreign(column).references(table, column)adds<table>_<column>_foreignwith optionalon_delete/on_update(Cascade,Restrict,SetNull,NoAction).foreign_id(column, table)also adds theINTEGER NOT NULLcolumn and referencestable(id). The column type must match the referenced one (id()isINTEGER),SetNullneeds a nullable column, and SQLite enforces foreign keys only withPRAGMA foreign_keys = ON, which SQLx sets on every connection it opens.check(expression)addsCHECK (expression). The expression is trusted developer SQL emitted as written: building fails when it is empty, longer than 512 bytes, or contains;, a comment,\,$, a backtick, a control character or unbalanced parentheses or quotes, but never build it from request data. MySQL enforces CHECK from 8.0.16 and MariaDB from 10.2.1.- On MySQL/MariaDB a unique, index or foreign-key column whose type is
TEXT,BLOBorJSON(a plainstring(), ortimestamps()on theAnypool) fails before any DDL runs: set a boundedcol_typesuch asVARCHAR(255).
A unique or foreign-key violation from a later write returns
Error::UniqueViolation/Error::ForeignKeyViolation; PostgreSQL reports the
constraint name.
Schema::table alters an existing table with the same builders: it adds the
blueprint’s columns (ALTER TABLE ... ADD COLUMN), then its unique
constraints, indexes, foreign keys and checks, all validated before the first
statement runs:
#![allow(unused)]
fn main() {
use rullst_orm::schema::{Blueprint, ForeignKeyAction, Schema};
async fn add_reviewer() -> Result<(), rullst_orm::Error> {
Schema::table("posts", |table: &mut Blueprint| {
table.integer("reviewer_id");
table
.foreign("reviewer_id")
.references("users", "id")
.on_delete(ForeignKeyAction::SetNull);
table.index(&["reviewer_id"]);
}).await?;
Ok(())
}
}
SQLite cannot add a constraint to an existing table. There a unique
constraint, or a unique() column, becomes the equivalent unique index, and a
foreign key on a column added in the same call becomes that column’s
REFERENCES clause (the column must be nullable without a default). Any other
foreign key (on an existing column, or a second one on the same new column), a CHECK, a primary-key column, a NOT NULL column without a
default or a current-time default returns Error::UnsupportedSchemaChange
before any statement runs; rebuild such a table with a new Schema::create.
MySQL/MariaDB commit each DDL statement on its own, so a failure there can leave
the earlier statements applied.
Date and time columns
date(), time(), timestamp() and timestamp_tz() hold chrono values
(re-exported as rullst_orm::chrono, and in the facade as
rullst::orm::chrono):
| Helper | Rust field | PostgreSQL | MySQL/MariaDB | SQLite (ISO-8601 text) |
|---|---|---|---|---|
date | NaiveDate | DATE | DATE | 2026-10-15 |
time | NaiveTime | TIME | TIME(6) | 09:30:00.500 |
timestamp | NaiveDateTime | TIMESTAMP | DATETIME(6) | 2026-10-15 09:30:00.500 |
timestamp_tz | DateTime<Utc> | TIMESTAMPTZ | DATETIME(6) (UTC) | 2026-10-15T09:30:00.500+00:00 |
MySQL/MariaDB store a UTC instant in DATETIME(6) because their TIMESTAMP
ends in 2038; SQLx sets the session time zone to +00:00, so values and
CURRENT_TIMESTAMP defaults are UTC (other clients see the same UTC wall
time). A ColumnDefault::CurrentTimestamp default is the current UTC time in
the column’s own form: CURRENT_TIMESTAMP on a PostgreSQL TIMESTAMPTZ, the
UTC wall time (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') on the zone-less types,
CURRENT_TIMESTAMP(6), (CURRENT_DATE) or (CURRENT_TIME(6)) on
MySQL/MariaDB, and on SQLite the same text SQLx writes, so stored values of one
column compare and sort as text (strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now')
for timestamp_tz).
table.timestamps() adds nullable created_at/updated_at timestamp_tz()
columns that default to the current time. Model fields, filters and ordering
use the chrono types; RullstValue converts them (where_gt("starts_at", Utc::now())), and #[orm(db_default)] leaves a column to its database
default:
// Needs a strict driver feature (see below).
use rullst_orm::chrono::{DateTime, NaiveDate, Utc};
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(table = "events")]
pub struct Event {
pub id: i32,
pub starts_at: DateTime<Utc>,
pub day: NaiveDate,
pub ends_at: Option<DateTime<Utc>>,
// Never written by `save()`: INSERT keeps the column DEFAULT and UPDATE
// keeps the stored value. The handle keeps its own value (here `None`)
// until the row is read again.
#[orm(db_default)]
pub created_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
}
#[orm(db_default)] fields are left out of the generated INSERT and of
every UPDATE of save(), update_partial() has no setter for them and
#[derive(Nexus)] shows them read-only. Set an updated_at without it
yourself (Utc::now()) or maintain it with a trigger. A model with a
RowSnapshot field refreshes them from the stored row after each update.
#[orm(db_default)] is also the option for any column that the database or
your own SQL manages, such as the generated users.is_admin and
users.session_epoch: queries can filter, order, group and select it, and
ORM writes never touch it. #[orm(skip)] means something else: the field is
not a database column for the ORM, so a filter, ordering, grouping or
select on it fails with Error::Validation. A skipped field that the table
does not have also needs #[sqlx(skip)] (or #[sqlx(default)]), because the
model’s FromRow still reads #[orm(skip)] fields.
| Option | The column | Filters, ordering, grouping, select | ORM writes |
|---|---|---|---|
#[orm(skip)] | is not a database column for the ORM | rejected | never |
#[orm(db_default)] | exists and is managed by the database | allowed | never |
Date/time columns and chrono fields need a strict driver feature
(strict-postgres, strict-mysql or strict-sqlite, which generated projects
use). SQLx’s dynamic Any pool, selected by drivers-all without a
strict feature, carries only integer, float, text and blob values: it cannot
decode a chrono field, and it cannot read a PostgreSQL or MySQL/MariaDB
DATE, TIME, TIMESTAMP or TIMESTAMPTZ column at all. On that pool
Schema::create and Schema::table reject those helpers on PostgreSQL and
MySQL/MariaDB with Error::Validation, SQLite keeps its ISO-8601 text for
String fields, chrono filter values bind as that same text, and
timestamps() keeps the alpha.1 TEXT DEFAULT CURRENT_TIMESTAMP columns
(DEFAULT (CURRENT_TIMESTAMP) on MySQL/MariaDB, which accept a default on
TEXT, BLOB, JSON and GEOMETRY columns only as an expression; other
non-NULL defaults on those types are wrapped the same way, MySQL 8.0.13+,
MariaDB 10.2.1+). soft_deletes() stays a TEXT deleted_at column on every
pool; declare timestamp_tz("deleted_at") for a native one. The Turso adapter
does not use the Blueprint and keeps text timestamps.
table.float(...) and table.boolean(...) map to f64 and bool model
fields. PostgreSQL receives DOUBLE PRECISION and BOOLEAN (an integer
ColumnDefault of 0/1 becomes FALSE/TRUE); MySQL/MariaDB receive
DOUBLE and an INTEGER 0/1 flag; SQLite keeps REAL and INTEGER. This
applies to newly built DDL only: PostgreSQL columns created by earlier versions
remain REAL/INTEGER until a reviewed migration alters them, and a model that
paired boolean() with an integer field on PostgreSQL must switch to bool
(or use integer()) for new tables.
📚 Documentation
We recently launched a brand-new Interactive Documentation Hub!
👉 Explore the Full Documentation in the Rullst Book
🛡️ Security
Rullst ORM uses SQLx prepared-statement bindings for values accepted by its query builders. Structural identifiers are restricted to a bounded ASCII identifier grammar before interpolation. Raw SQL and application authorization remain the caller’s responsibility; these controls reduce injection risk but are not an absolute safety guarantee.
📄 License
This project is licensed under the MIT License.
Transactional partial updates
Since v13 (first released in 13.0.0-alpha.1), a SQLx model’s typed partial change participates in the model’s normal save lifecycle. A lesson editor can submit a new title while preserving a note that another operation has already changed, applying its policy, recording the audit and refreshing projections after commit.
Local SQLite, PostgreSQL and MySQL journeys, cancellation, Redis cache, Scout, MSRV 1.96, strict linting and an extracted-facade consumer passed. Hosted workspace/platform and extracted-package source admission passed in PR #236. Final release admission remains separate.
Application use
The consumer needs only its selected rullst ORM/database features: since
13.0.0-alpha.4 the code that #[derive(Orm)] generates, the model’s
FromRow included, reaches SQLx, tracing and tokio through rullst_orm.
The extracted-package consumer
records a compiled dependency profile (rullst with strict-sqlite, plus
tokio for its #[tokio::test]).
#![allow(unused)]
fn main() {
#[cfg(feature = "orm")]
mod example {
use rullst::orm as rullst_orm;
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(table = "lessons", tenant_column = "tenant_id", auditable)]
pub struct Lesson {
pub id: i32,
pub tenant_id: String,
pub title: String,
pub note: Option<String>,
}
// The host first authenticates the editor and authorizes this lesson.
// Supply tenant/actor from trusted identity, not request-body assertions.
pub async fn rename(
lesson: &mut Lesson,
trusted_tenant: String,
trusted_editor: String,
title: String,
) -> Result<(), rullst_orm::Error> {
let actor = rullst_orm::audit::AuditContext::user(trusted_editor)?;
rullst_orm::tenant::with_tenant(trusted_tenant,
rullst_orm::audit::with_audit_context(actor,
lesson.update_partial().title(title).save(),
),
).await
}
pub async fn rename_in_transaction(
lesson: &mut Lesson,
tx: &mut rullst_orm::db::Transaction<'_>,
title: String,
) -> Result<(), rullst_orm::Error> {
lesson.update_partial().title(title).save_with_tx(tx).await
}
}
}
Create/migrate the application and audit tables before serving requests. An
update on an auditable model now requires the same AuditContext as a full save.
Tenant scoping does not establish ownership or grant an editor permission.
Existing application routes keep their authentication, CSRF, security headers
and ownership checks.
update_partial() excludes the primary key and declared tenant column from its
setters. The soft-delete column keeps its setter, but setting it fails with
Validation; only delete(), restore() and force_delete() change that
marker. note(None) explicitly clears a nullable field; omitting the setter
preserves that field from the persisted row. An empty builder remains a no-op,
without reading the database, running hooks or creating an audit entry.
Database and lifecycle behavior
A nonempty patch opens a savepoint, validates the model handle’s active tenant,
and reads the current row by bound primary key and tenant. Missing rows fail
with RecordNotFound. PostgreSQL and MySQL use SELECT ... FOR UPDATE so a
competing update cannot change that row between the read and write. SQLite
retains its transaction semantics; a contended read-to-write upgrade can fail
with a database error. There is no automatic retry.
The row must decode through the selected SQLx profile before the patch can
write. This differs from a bare selected-column update: incompatible database
types now fail before mutation. For example, SQLx Any 0.9 rejects SQLite
BOOLEAN column metadata; select and validate a typed backend profile for
backend-specific types instead of assuming every Any schema can be read.
Selected values are merged into the fresh model. The normal generated save then runs its policy, before/after hooks, observers, encryption, audit and post-commit registrations. A policy, hook, SQL or audit failure rolls the operation’s savepoint back and discards its queued effects. Reentrant implicit ORM reads inside borrowed mutation callbacks fail validation; load required policy data explicitly before the mutation, as for full saves.
The successful direct path commits its own transaction, replaces the caller’s
object with the fresh merged model, and runs committed observers, cache
invalidation and Scout projections. A PostCommit error means that SQL has
already committed; it does not authorize replaying the domain mutation. These
callbacks are process-local. Use an explicit transactional outbox for effects
that need durable recovery.
This implementation saves the merged row through save(): a full-row SQL
update, or, for a model with a RowSnapshot field (dirty tracking), an update
of only the patched columns whose value differs from the locked row. The extra
read and savepoint have a cost; row-read privileges, update triggers and
privileges and encrypted-field re-encryption follow save() behavior. Model hooks may transform the candidate
under their usual contract. The loaded row first runs the model’s after_fetch
hook, exactly as find() returns it, so a before_save mutator paired with an
after_fetch accessor is applied once rather than to the stored
representation again. The hook runs while the transaction is borrowed, so ORM
access from inside it fails closed. Review hooks that assumed partial updates
skipped them, and database triggers that react to the SQL column list.
Caller-owned transactions
The new save_with_tx(&mut transaction) terminal method supports an explicit
borrowed transaction. Ordinary .save() also reuses the active
Orm::transaction task scope. Neither commits the enclosing transaction.
The rename_in_transaction example above uses that explicit terminal method.
A successful scoped update replaces the object with the transaction’s tentative row. If a later operation rolls that transaction back, discard or reload the object; the ORM cannot retroactively mutate an application reference after its borrow has ended. Catching a failed patch and continuing the managed transaction does not promote that failed savepoint’s audit or effects.
Strict post-commit timing requires the direct owned path or Orm::transaction.
A raw SQLx transaction cannot report its eventual commit decision to these
callbacks, so it retains the existing raw-transaction limitation. Do not treat
savepoint release as proof that a caller-owned transaction is durable.
Migration and evidence
For existing update_partial() call sites, review:
- Required audit identity, tenant context and the model’s newly applied hooks.
- Database update triggers/privileges (full-row, or only the changed columns
for a model with a
RowSnapshotfield) and the extra read/savepoint. - Unsaved fields and loaded relations in the old object: successful updates refresh them from the database representation, rather than retaining stale local values. Only explicit setters submit field values.
- Post-commit errors, enclosing rollback and ambiguous commit responses. A database commit error can require reconciliation; do not assume every error proves that no write occurred.
The row lock merges current values; it does not reject a stale editor’s expected revision. Conflicting edits to the same field still need an application revision or compare-and-set rule. This is also not a bulk update, multi-shard transaction, background repair service or automatic schema migration.
The shared database journey exercises rejected policy/hooks/observers, constraints, missing audit context, tenant isolation, fresh-row merging, nullable fields, concurrent patches, explicit/task-scoped rollback, cancellation after SQL, audit deltas and post-commit failures. The same journey is part of the existing SQLite, PostgreSQL and MySQL matrices.
The separate Redis cache and Scout tests verify that committed patches refresh projections while rolled-back patches leave them intact. SQLite contention tests verify known rollback state before an explicit fresh retry. The linked source admission records the corresponding hosted and extracted-package results.
Rullst Connect 🦀
Important
This page describes the v13 prerelease, published as
13.0.0-alpha.4. Cargo never selects a prerelease on its own, so its dependency examples pin that version exactly. The v12 line (rullst-connect@12) reached end of life on 16 October 2026; see the release record. Use a path dependency only for checkout-local review.
Since 12.1.2 (and in v13), built-in
authorization-code URLs explicitly request response_type=code. The
OAuth protocol and
Google request contract
require this field. Offline parsed-query tests cover all eleven providers;
real account interoperability remains separate. Temporary workarounds for
earlier 12.x releases should add the field only when absent.
Vision preserved: message brokers, additional queue transports, remote storage, and media work are retained with explicit status and recommendations in the capability ledger.
Rullst Connect is an async OAuth2/OIDC client layer with a shared
Provider interface and normalized ConnectUser output. Provider-specific
protocols, scopes, claims, and application account-linking rules still require
explicit review.
🛡️ Security Engineering
Rullst Connect uses layered tests and repository security checks. CI badges report the state of those checks for the referenced commit; they are not an absolute security guarantee.
✨ Features
- 🚀 Async HTTP: Built on Tokio-compatible request paths and
reqwest. - 🧩 Standardized: All providers return a unified
ConnectUserstruct. - 🛡️ Type-Safe: Robust error handling using
thiserror(ConnectError). - 🔌 Framework adapters: Core provider APIs are framework-independent; optional extractor features cover the integrations declared in the crate.
- 🔐 Login challenge store (v13, 13.0.0-alpha.3):
begin_loginandfinish_loginkeep state + PKCE + OIDC nonce in aChallengeStore(memory, or the application’s SQLite, PostgreSQL or MySQL pool) behind an opaque id in a shortHttpOnly,SameSite=Laxcookie, and consume each challenge exactly once.cargo rullst make:oauth google|github|microsoft|applegenerates it for Rullst accounts. - 🔐 Managed callback transaction: The optional Axum/tower-sessions path generates, stores, expires, validates, and consumes state + PKCE + OIDC nonce.
- 🔏 Encrypted token snapshots: Versioned AES-256-GCM envelopes bind a refresh generation to one trusted provider/account pair and an explicit key rotation ID before application-owned persistence.
- 🔐 OIDC Security: Strict discovery validation plus isolated JWKS caches with TTL, single-flight refresh on unknown
kid(at most once per 30 seconds per JWKS URL, after rejecting malformedkidvalues without I/O), and bounded stale-if-error behavior.OidcProviderverifies an ID token withoutkidonly against a single-key JWK Set whose key fits the token’s asymmetricalg(OIDC Core 10.1); other sets fail closed. - 🚪 Typed remote revocation: Access and refresh tokens are distinct API operations. Google, GitHub, Discord, Apple, Auth0 and Cognito have bounded protocol adapters; unsupported providers fail explicitly and offline credentials remain network-free.
- 🏢 Explicit Corporate Proxy: First-class HTTP(S) proxy clients, including bounded Basic proxy authentication without credentials in the endpoint URL.
- 📺 Device Flow: RFC 8628 device authorization for headless CLI and Smart
TV auth through
Provider::request_device_code/poll_device_token; the GitHub adapter implements it and other providers return an explicit error. - 🛠️ Testing: With the
mockfeature (rullst/oauth-mockthrough the facade), empty ormock_*credentials select a deterministic offline transport; without it,try_newrejects them.mock_idpsupplies a loopback-only signed OIDC fixture with one-shot codes, PKCE, nonce, EdDSA ID tokens and JWKS.
📚 Important Documents:
- CHANGELOG.md: See what’s new.
- ISSUES: Any issue? Please report.
- AUDIT.md: Repository audit record; current workflow evidence remains authoritative.
📦 Supported Providers
Official support for 11 core providers:
- GitHub (OAuth 2.0, not OpenID Connect:
ConnectUser::idis the numeric account id ofGET /user, which never changes, never the login name. From 13.0.0-alpha.3emailis the addressGET /user/emailsmarksprimaryandemail_verifiedis itsverifiedflag; the defaultuser:emailscope grants the call. When it fails or lists no primary address,emailis the public profile address andemail_verifiedisNone) - Microsoft / Azure AD, two adapters:
MicrosoftOidcProvider(v13, from 13.0.0-alpha.3) signs in with OpenID Connect for an explicitMicrosoftTenant:Common(any Entra ID organization and personal accounts),Organizations,Consumersor one directory ID (MicrosoftTenant::parsereadscommon,organizations,consumersor a GUID). It verifies the RS256 ID token againsthttps://login.microsoftonline.com/<tenant>/discovery/v2.0/keys, requires the issuerhttps://login.microsoftonline.com/<tid>/v2.0of the token’s owntidand refuses atidthe tenant choice does not admit.ConnectUser::idis<tid>:<oid>, Microsoft’s stable key for a user (subis pairwise per application).emailis the optionalemailclaim, which Microsoft does not verify;email_verifiedisSome(true)only when the token also carries the optional claimxms_edov: true(requestemailandxms_edovin the app registration’s Token configuration), otherwiseNone. It has noget_user_from_token: the identity comes only from the code exchange.MicrosoftProviderreads Microsoft Graph/methrough the multi-tenantcommonauthority, so any Entra tenant and personal Microsoft accounts can sign in, and returns no tenant ID;emailis Graphmailor, failing that,userPrincipalName, which tenant administrators control and which may not be a mailbox, soemail_verifiedisNone. Never link accounts or grant tenant access on it.
- Apple (Sign in with Apple;
AppleProvider::verify_signing_keychecks at startup that the.p8key can sign the ES256 client secret, andAppleProvider::name_from_user_fieldreads the unsigned first-login name from the callback’suserfield, from 13.0.0-alpha.3) - Auth0
- AWS Cognito
- X (Twitter) (Strict PKCE requirement; confidential clients authenticate
to the token endpoint with HTTP Basic,
client_secret_basic) - Discord
- OIDC (OpenID Connect Custom Provider) (sends the client secret in the
token request body unless discovery lists
client_secret_basicwithoutclient_secret_post, in which case it uses HTTP Basic)
Tokens supplied by native or mobile clients
Provider::get_user_from_token is not an authentication primitive for a
token that a client (for example a native or mobile app) sends to your backend.
Except for Apple, it sends the bearer token to the provider’s userinfo or profile
endpoint, which proves only that the token is valid for some client of the
provider, not that it was issued to your client_id. Another app that obtained
a user’s token for its own client ID could replay it and be signed in as that
user.
Sign users in with your own authorization-code exchange (get_user with state,
PKCE and, for OIDC, nonce), or with an ID token whose signature, issuer,
audience (your client_id), expiry and nonce are verified for your client.
Use get_user_from_token only for tokens your server obtained itself. Apple’s
adapter treats its argument as an ID token and checks signature, issuer,
audience and expiry but no nonce, so a captured Apple ID token for your client
can be replayed until it expires.
v13 (since 13.0.0-alpha.1) adds that audience-bound entry point for
Google and OidcProvider: verify_id_token(id_token, expected_nonce) verifies
the signature through the provider’s rotating JWKS and requires the exact
issuer, aud equal to your client_id (and, when present, an azp equal to
it or, for Google, to a configured native presenter),
valid exp/iat and the nonce your server issued for that sign-in attempt. It
never calls userinfo. The returned ConnectUser carries the verified ID token
in access_token; there is no provider access or refresh token in this flow.
OidcProvider treats profile claims as optional (OIDC Core 5.1) in ID tokens
and userinfo alike: without name, ConnectUser::name falls back to
given_name and family_name, then preferred_username, then nickname, and
is empty when none is present. The email address and subject are never used as
a display name.
#![allow(unused)]
fn main() {
use rullst_connect::prelude::{ConnectError, ConnectUser, GoogleProvider};
async fn sign_in_native_google_user(
google: &GoogleProvider,
id_token: &str,
nonce_issued_for_this_attempt: &str,
) -> Result<ConnectUser, ConnectError> {
google
.verify_id_token(id_token, nonce_issued_for_this_attempt)
.await
}
}
Generate the nonce on your server, give it to the client for the provider
sign-in request, and consume it once, just like an OAuth state.
Android Credential Manager and iOS Google Sign-In request the ID token for your
server (web) client ID, so aud is that ID and azp is the Android or iOS
client ID. Configure the server client ID on GoogleProvider and list the
native client IDs with
try_with_authorized_presenters(["ANDROID_CLIENT_ID", "IOS_CLIENT_ID"]) (at
most 16). aud must still equal the server client ID; any other azp is
rejected, and the authorization-code flow keeps requiring azp to equal the
server client ID.
Remote token revocation is deliberately narrower than login support. Use
Provider::revoke_token for an access token and
Provider::revoke_refresh_token for a refresh token; Auth0/Cognito accept only
the latter through these adapters, while GitHub accepts only the former. A
successful provider response does not clear application cookies, sessions,
cached identity, or durable token records—the host must commit that local
logout lifecycle. Provider revocation endpoints are intentionally idempotent in
several ecosystems, so HTTP success is protocol acceptance, not proof that the
token was valid immediately before the call.
🛠️ Installation
Inside a source checkout, a path dependency intentionally follows that tree. For a reproducible application, install the exact registry version instead.
v13 prerelease command (the v12 line reached end of life on 16 October 2026):
cargo add rullst-connect@=13.0.0-alpha.4
cargo add secrecy
A Rullst application can use the facade instead: the rullst feature oauth
re-exports this crate as rullst::connect with its Axum integration
(rullst-connect/rullst), so rullst::connect::extractors::AuthCallback works
as a handler argument without a direct dependency. The login challenge
store is rullst::connect::challenge; oauth-sqlite, oauth-postgres and
oauth-mysql add its SQL store for that database. The managed
tower-sessions transaction below (axum-session) still needs a direct
dependency.
For the recommended Axum session transaction, enable axum-session and add a
tower-sessions store:
rullst-connect = { version = "=13.0.0-alpha.4", features = ["axum-session"] }
tower-sessions = "0.15"
Or manually add it to your Cargo.toml:
[dependencies]
rullst-connect = "=13.0.0-alpha.4"
secrecy = "0.10"
tokio = { version = "1.52", features = ["full"] }
🚀 Quick Start
1. Initialize the Provider
Choose your provider and pass your credentials and callback URL:
use rullst_connect::prelude::*;
fn main() -> Result<(), ConnectError> {
let github = GithubProvider::try_new(
"YOUR_CLIENT_ID",
"YOUR_CLIENT_SECRET".to_string().into(),
"http://localhost:3000/auth/github/callback",
)?;
let _ = github;
Ok(())
}
Credential modes and offline development
Every provider exposes credential_mode() -> CredentialMode. An empty
credential or a value beginning with mock_ selects CredentialMode::Mock,
a deterministic transport that never accesses the network and signs in a fixed
mock user. That mode needs the Cargo feature mock (or oauth-mock on the
rullst facade). Without it, try_new, Connect::driver and
OidcProvider::discover return ConnectError::InvalidConfiguration for the
field credentials when they receive such a value, so a missing production
secret fails when the provider is built instead of at the login callback; the
deprecated new constructors return a disabled provider. Enable the feature
only in development and test builds, because it turns mock credentials into a
working sign-in. In a facade application, run cargo run --features dev-oauth
with:
[features]
dev-oauth = ["rullst/oauth-mock"]
With a direct rullst-connect dependency, name rullst-connect/mock instead,
or enable it for tests only:
[dev-dependencies]
rullst-connect = { version = "=13.0.0-alpha.4", features = ["mock"] }
In mock mode the authorization URL is the configured redirect_url with the
fixed code=mock_authorization_code, the state and any PKCE challenge, as a
real provider redirects after consent. The browser flow, including the managed
axum-session transaction, therefore runs locally without a login page;
Apple’s mock also uses a query string instead of a form post. Mock profiles use
the reserved example.invalid email domain. For a real protocol exchange with
discovery, one-shot codes and signed ID tokens, use the fixture below.
Signed local OIDC fixture
Enable axum and mount mock_idp::mock_router_with_config only on an exact
loopback listener. MockIdpConfig::try_new also rejects non-loopback issuer and
callback URLs. The resulting fixture exercises discovery, an exact registered
client/callback, expiring one-shot authorization codes, optional nonce, S256
PKCE, EdDSA ID-token validation through JWKS and bearer-protected userinfo.
Its signing seed and credentials are deterministic public test material. It is not a production identity provider, interactive login/consent UI, refresh-token service, federation implementation or OIDC conformance suite. Follow the local OIDC testing tutorial for a complete loopback setup.
Explicit corporate proxy
Live providers can receive a first-class proxy-aware transport without relying
on ambient HTTP_PROXY state:
#![allow(unused)]
fn main() {
use rullst_connect::client::ReqwestClient;
use rullst_connect::prelude::{ConnectError, GithubProvider};
use std::sync::Arc;
fn github_through_corporate_proxy(
proxy_password: impl Into<String>,
) -> Result<GithubProvider, ConnectError> {
let proxy = ReqwestClient::try_with_proxy_basic_auth(
"https://proxy.corp.example:8443",
"proxy-user",
proxy_password,
)?;
let github = GithubProvider::try_new(
"YOUR_CLIENT_ID",
"YOUR_CLIENT_SECRET".to_string().into(),
"http://localhost:3000/auth/github/callback",
)?;
Ok(github.with_http_client(Arc::new(proxy)))
}
}
With the retry feature, a provider’s with_retry replaces its transport with
a new direct client, so call it before with_http_client, or not at all: the
proxy constructors already apply the bounded retry policy.
OidcProvider::discover fetches its metadata before with_http_client can
apply, so pass the proxy client to OidcProvider::discover_with_client instead
(added in v13); discovery, JWKS and token calls then share it.
Proxy URLs are limited to an HTTP(S) scheme and authority, with no embedded credentials, path, query, or fragment. Authenticated non-loopback proxies must use HTTPS. The configured client uses only that explicit proxy; PAC/WPAD, SOCKS, proxy mTLS identity and deployment certification remain outside this bounded transport.
2. Start a Server-Bound Authorization
A Rullst application signs users in with its own encrypted session cookie and
has no tower-sessions store. For it, rullst_connect::challenge keeps each
attempt in a ChallengeStore (v13, 13.0.0-alpha.3): begin_login stores the
state, PKCE verifier, nonce and an optional same-site return page and returns
the redirect plus an opaque id for a ChallengeCookie; finish_login
consumes the challenge once, compares the state in constant time and exchanges
the code with the stored verifier and nonce:
#![allow(unused)]
fn main() {
use rullst_connect::challenge::{LoginOptions, LoginRedirect, MemoryChallengeStore, begin_login};
use rullst_connect::challenge::LoginError;
use rullst_connect::providers::GoogleProvider;
async fn start_google(
store: &MemoryChallengeStore,
google: &GoogleProvider,
) -> Result<LoginRedirect, LoginError> {
// Redirect to `url()` and set `set_cookie(&ChallengeCookie::new("/auth/google", secure)?)`.
begin_login(store, google, LoginOptions::oidc("google")).await
}
}
SqlChallengeStore::open(pool, max_entries) stores them in the application’s
SQLite, PostgreSQL or MySQL database instead (features sqlite, postgres,
mysql), consuming each row with one atomic statement or transaction.
cargo rullst make:oauth google|github|microsoft|apple generates the
handlers, account linking and sign-in for a Rullst application. A provider
that POSTs the callback from its own site, such as Sign in with Apple, needs
ChallengeCookie::for_cross_site_post (SameSite=None; Secure): browsers
attach no SameSite=Lax cookie to that POST. See
Tutorial 42.
The axum-session path generates state and PKCE, stores their private
counterparts in tower-sessions for ten minutes, and returns only the redirect
URL:
#![allow(unused)]
fn main() {
use axum::response::Redirect;
use rullst_connect::prelude::{ConnectError, GithubProvider};
use rullst_connect::extractors::begin_oauth_session;
use tower_sessions::Session;
async fn start_github_authorization(
session: &Session,
github: &GithubProvider,
) -> Result<Redirect, ConnectError> {
let authorization = begin_oauth_session(session, github).await?;
Ok(Redirect::temporary(authorization.url()))
}
}
Use begin_oidc_session instead for Google or a custom OIDC provider. It adds
and stores an OIDC nonce as well.
Sign in with Apple is different: AppleProvider always requests
response_mode=form_post, so Apple returns code, state and id_token in a
cross-site POST body. AuthSession reads only the query string, and a
SameSite=Lax or Strict session cookie is not sent on that POST, so the
managed begin_oidc_session + AuthSession flow cannot complete an Apple
login. In v13, use the store-backed login above with
ChallengeCookie::for_cross_site_post, or AuthSessionForm below. The manual
state/PKCE/nonce flow with a dedicated SameSite=None; Secure; HttpOnly
challenge cookie, described in
Sign in with Apple: form POST callback,
also works and is the only option on 12.x.
v13 (since 13.0.0-alpha.1) adds AuthSessionForm, which consumes
the begin_oidc_session challenge from the form POST body (at most 16 KiB).
The session cookie that holds that challenge must still be
SameSite=None; Secure, so give the Apple routes their own session layer.
3. Consume the Callback and Get the User
AuthSession consumes the challenge before checking its expiry and state. It
then supplies the exact PKCE verifier and optional OIDC nonce to the provider:
#![allow(unused)]
fn main() {
use rullst_connect::extractors::AuthSession;
use rullst_connect::prelude::{
ConnectError, GithubProvider, Provider as _, UniversalProfile,
};
async fn finish_github_callback(
auth_session: AuthSession,
github: &GithubProvider,
) -> Result<UniversalProfile, ConnectError> {
let params = auth_session.exchange_params()?;
let user = github.get_user(params).await?;
Ok(user.universal_profile())
}
}
Only one managed challenge is active per browser session. Starting another login deliberately invalidates the earlier tab. The application must configure a durable production session store, Secure/HttpOnly/SameSite cookies, TLS, registered redirect URLs, account-linking policy, and post-login session rotation. See Server-Bound OAuth/OIDC Sessions.
🛡️ Manual State Handling
Non-Axum hosts may use the framework-neutral primitives directly. The host must atomically take the expected value from a short-lived server-side store before comparison; a reusable cookie value is not equivalent.
The following fragment is host pseudocode because the durable one-time store and callback extractor are deliberately application-provided:
use rullst_connect::pkce::generate_oauth_state;
let state = generate_oauth_state();
store_one_time_state(&state).await?; // application-provided durable operation
let url = github.redirect_url_with_state(&state);
let expected = take_one_time_state().await?; // atomically removes it
callback.verify_state(&expected)?;
🔄 Refreshing Tokens
If the provider returned expires_in plus a refresh token, bind the result to a
statically dispatched, process-local coordinator when the callback receives it:
#![allow(unused)]
fn main() {
use rullst_connect::{AutoRefreshingSession, ConnectError, ConnectUser};
use rullst_connect::prelude::ExposeSecret as _;
async fn send_token_to_the_authorized_api(_: &str) -> Result<(), ConnectError> {
Ok(())
}
async fn call_provider_api(
github: &rullst_connect::providers::GithubProvider,
user: &ConnectUser,
token_received_at: u64,
) -> Result<(), ConnectError> {
let session = AutoRefreshingSession::from_user_at(
github,
user,
token_received_at,
)?;
let lease = session.access_token().await?;
send_token_to_the_authorized_api(lease.access_token().expose_secret()).await?;
Ok(())
}
}
The default checks 60 seconds before expiration. Refresh calls cannot overlap;
callers waiting behind a successful refresh reuse its state. A response
replaces the access token only after the lifetime and original provider user ID
validate. If a response for the original user is otherwise rejected (for
example it omits expires_in), its rotated refresh token is still kept and the
generation advances, because the provider has already consumed the prior one;
the call fails and the next call refreshes with the rotation. The same
applies when the grant succeeds but the follow-up profile lookup or ID-token
validation fails: provider adapters then return
ConnectError::RefreshIncomplete (added in v13), whose IssuedTokens
carry the new tokens to direct Provider::refresh_token callers, and the
session keeps the rotation and returns the underlying error. Persist the
snapshot after such a failure too.
Do not cancel access_token() while it refreshes. The provider call runs
inside the caller’s future, so dropping that future (a client disconnect, a
tower timeout layer or tokio::select!) after the provider accepted the grant
discards its response: a provider that rotates or consumes refresh tokens has
then spent the credential this session still holds, and the next refresh fails
with invalid_grant (Auth0 rotation may also revoke the token family). Drive
refreshes from a task that is not cancelled with the request, or treat such an
invalid_grant as a reauthentication signal.
Use access_token_at in deterministic workers/tests. Seal
state_snapshot() with EncryptedTokenSnapshot before writing it to a
dedicated application store:
#![allow(unused)]
fn main() {
use rullst_connect::{
EncryptedTokenSnapshot, RefreshableTokenState, TokenSnapshotBinding,
TokenSnapshotError, TokenSnapshotKey,
};
fn seal_for_storage(
state: &RefreshableTokenState,
key_bytes: [u8; 32],
local_account_id: &str,
) -> Result<EncryptedTokenSnapshot, TokenSnapshotError> {
let binding = TokenSnapshotBinding::try_new("github", local_account_id)?;
let key = TokenSnapshotKey::try_new("oauth-primary-2026", key_bytes)?;
EncryptedTokenSnapshot::seal(state, &key, &binding)
}
}
Persist only snapshot.as_str(). On restart, validate it with
try_from_envelope, select the secret-manager key named by key_id(), and call
open with the same trusted binding. Keys must be 32 bytes from a secret
manager or CSPRNG; human passwords require an application-selected KDF. The
envelope authenticates confidentiality, ownership binding and generation but
does not provide a database transaction by itself.
Durable shared-local SQLite state
Enable rullst-connect/sqlite (or umbrella rullst/oauth-sqlite) to store the
encrypted generations in one local SQLite file:
#![allow(unused)]
fn main() {
use rullst_connect::{
RefreshableTokenState, SqliteTokenSnapshotStore, TokenSnapshotBinding,
TokenSnapshotKey, TokenStoreError,
};
async fn persist_initial_generation(
state: &RefreshableTokenState,
key: &TokenSnapshotKey,
account_id: &str,
) -> Result<(), TokenStoreError> {
let store = SqliteTokenSnapshotStore::connect(
"sqlite:///var/lib/my-app/oauth-tokens.sqlite",
50_000,
)
.await?;
let binding = TokenSnapshotBinding::try_new("github", account_id)?;
store.insert_initial(&binding, state, key).await?;
store.close().await;
Ok(())
}
}
After refreshing from generation n, call
compare_and_swap(&binding, n, &replacement, &key). BEGIN IMMEDIATE
serializes local writers and only generation n + 1 can replace the observed
row. The fixed schema persists its configured 1–1,000,000 row ceiling and
restart/key metadata; malformed rows, configuration drift, quota exhaustion
and stale replacement/deletion fail with typed redacted errors. The account
locator is a SHA-256 pseudonymous digest, not an anonymity guarantee; the
provider/account binding is also authenticated inside the ciphertext.
This local CAS does not serialize the earlier call to a remote OAuth provider. Applications must still authorize the account, lease that remote operation, reconcile a provider rotation lost to CAS, configure retry/backoff and local logout, keep keys in a secret manager, protect/backup the directory and provide multi-host replication when needed. A provider that does not support refresh continues to return a typed error.
🔒 Manual PKCE Support
Provider adapters expose PKCE (Proof Key for Code Exchange) where supported by the provider protocol. Some providers such as X (Twitter) v2 require it; applications must preserve and validate the verifier/state for the complete authorization transaction. The generic provider boundary accepts both values explicitly:
#![allow(unused)]
fn main() {
use rullst_connect::{
ConnectError, ConnectUser,
pkce::{generate_oauth_state, generate_pkce},
provider::{ExchangeParams, Provider},
};
async fn exchange_with_pkce<P: Provider>(
provider: &P,
authorization_code: &str,
) -> Result<ConnectUser, ConnectError> {
let state = generate_oauth_state();
let (code_verifier, code_challenge) = generate_pkce();
// Persist state + verifier in a short-lived server-side record before redirecting.
let authorization_url =
provider.redirect_url_with_pkce_and_state(&code_challenge, &state);
let _ = authorization_url;
// After atomically consuming and validating that state in the callback:
provider
.get_user(ExchangeParams {
auth_code: authorization_code,
code_verifier: Some(&code_verifier),
..Default::default()
})
.await
}
}
🧑💻 Full Example with Axum
A complete Axum server is in rullst-connect/examples/axum_server.rs. From the
repository root, without provider credentials (the mock feature makes the
mock_* fallback credentials sign in offline):
cargo run -p rullst-connect --example axum_server --features mock
📦 Releasing a New Version
Connect is released only through the repository-wide, topologically ordered release workflow. Do not publish this crate independently from a working tree. Follow the release guide and require all candidate-SHA gates before creating a release tag.
🤝 Contributing
Feel free to open Issues and submit Pull Requests! Want to add a new provider? It’s easy! Just implement the Provider trait.
📄 License
This project is licensed under the MIT License.
rullst-messaging
rullst-messaging (Extension tier) is the broker-neutral event boundary for Rullst. It is kept
separate from rullst-connect because OAuth/OIDC identity federation and
message-broker delivery have different security, availability, and retry
semantics.
Current status
The crate has an implemented, bounded broker foundation:
- a versioned immutable envelope;
- bounded identifiers, headers, payloads, batches, leases, and retention;
- topic-scoped idempotent publication;
- consumer-group fan-out and competing-consumer claims;
- expiring single-use acknowledgement tokens;
- bounded retry, dead-letter views, and explicit purge;
- deterministic time injection and reusable contract tests;
- a canonical bounded v1 envelope wire codec with a deterministic byte fixture;
- allowlisted W3C
traceparent/tracestatepropagation without baggage; - a feature-gated SQLite adapter that transactionally retains publications, subscriptions, claims, ACK/retry/DLQ state and idempotency across restart;
- an explicit SQLite AES-256-GCM profile for header values and payloads, with immutable profile selection and bounded primary/prior-key rotation;
- an opt-in static relational ORM outbox relay with exact stream/topic binding and publish-before-ACK crash/replay evidence;
- added in v13:
schedules-postgresrecurring publications andwebhooksoutgoing webhooks.
The InMemoryBroker is suitable for offline tests, deterministic development,
and explicitly process-local workloads. SqliteBroker uses a fixed schema and
serialized SQLite write transactions; restart, two-instance contention,
configuration drift and corrupt-row repair are tested. It is a durable local
adapter, not a remote transport. The optional
Redis Streams transport (redis-streams feature, added
in v13) provides a standalone transport with Rullst-owned group and fenced-lease
indexes; hosted source/package admission passed in PR #236 and final release
admission remains separate. Kafka, RabbitMQ,
NATS/JetStream, SQS/SNS, Google Pub/Sub and Pulsar adapters remain roadmap work.
The wire codec is not a remote adapter: it neither opens broker connections nor maps a provider’s publish/ACK/retention semantics. Trace sampling, exporting, retention and tenant-aware correlation also remain host policy.
Security and correctness boundary
Payloads, idempotency values, acknowledgement tokens, and header values are not included in debug output. Tracing emits only bounded routing and decision metadata. Applications must still authorize who may publish or consume each topic and must make external effects idempotent.
Delivery is at least once. A valid acknowledgement consumes its lease exactly once, but no local ACK can make an arbitrary remote side effect atomic. Use the stable envelope ID at the side-effect boundary.
SqliteBroker::connect keeps the compatible plaintext profile.
connect_encrypted uses randomized AES-256-GCM and authenticates immutable row
metadata. Raw-storage, restart, wrong-key, tamper, row-swap, rotation, symlink
and two-instance regressions are executable. It protects header values and
payloads, not routing/idempotency/delivery metadata or the complete database.
The deployment still owns keys, database-file permissions, protected backups,
rollback detection, retention, disk monitoring and topic/tenant authorization.
Reopening a namespace with different limits or a different storage profile
fails closed instead of silently changing retained semantics.
The outbox relay does not make ORM and broker state one atomic transaction. It uses the committed event key for exact broker replay, publishes first and ACKs the exact ORM lease second. The application still supervises workers, retries, dead letters, cleanup, tenant/topic authorization and destination idempotency.
Continue with the brokered messaging tutorial or inspect the crate roadmap.
Rullst Mail 📬
Important
This page describes the v13 prerelease, published as
13.0.0-alpha.4. Cargo never selects a prerelease on its own, so its dependency examples pin that version exactly. The v12 line (rullst-mail@12) reached end of life on 16 October 2026; see the release record. Use a path dependency only for checkout-local review. Items marked (v13) and other recent fixes are not in the published 12.1.x crates.
Vision preserved: additional providers and air-gapped/zero-leak ambitions were not silently removed; see their status and recommendation in the capability ledger.
rullst-mail is Rullst’s transactional email and mailables engine. Official dispatch paths pass through a pre-flight pipeline for CRLF protection, recipient checks, content security scanning, and DLP sanitization before queueing or transport delivery.
Delivery providers (v13)
Version 13 ships four delivery transports: Resend, AWS SES,
SendPulse and SMTP, plus the log, memory and offline mock_*
drivers for development and tests. The SendGrid, Postmark, Mailjet, Mailtrap
and Azure Communication Services transports were removed in 13.0 and remain in
the 12.x line. A MAIL_DRIVER or [mail] driver that names one of them fails
with MailError::ConfigError naming the driver, never falling back to another
driver, and cargo rullst upgrade reports their names as V13-MAIL-REMOVED;
see the migration guide
row “Mail providers removed”. Mail already queued by 12.x carries no provider
name and is delivered by the driver the upgraded worker is configured with.
Any other provider is an application-owned MailDriver:
Writing your own mail transport walks through a
complete offline example (cargo run -p rullst-mail --example custom_transport).
SendPulse shipped in 12.1.0. Set MAIL_DRIVER=sendpulse with
SENDPULSE_API_KEY; real delivery requires an explicit verified sender. The
crate guide
details authentication, attachment/inline limits, tracking configuration,
unsubscribe headers and at-least-once retries. No extra feature is needed beyond
Mail. Provider acceptance still requires the configured account/domain.
12.1: welcome and password recovery
The 12.1 account-mail guide documents the opt-in PostgreSQL/SQLite account transaction, durable delivery bridge, action-link fix, localized lifecycle templates and signed feedback. These additions shipped in 12.1.0; its Azure Communication Services transport was removed in 13.0. Existing applications must adopt the new store/worker explicitly.
Shared suppression (v13)
Optional postgres (facade mail-postgres) adds PostgresSuppressionStore, a
recipient suppression and replay store shared by hosts using one PostgreSQL
database. Install the guard in every sending process and worker; it is not
enabled automatically. See the
shared suppression contract.
✨ Features
- 🛡️ Typed failures: production delivery paths return
MailError; malformed messages and provider configuration fail closed. CI and formal checks remain scoped evidence, not an absolute guarantee. - ⚡ Delivery and Test Drivers:
- Resend (
ResendDriver) — Native REST API with scheduled delivery & RFC 8058. - SendPulse (
SendPulseDriver) — Native transactional REST adapter; see the section above. - AWS SES v2 (
AwsSesDriver,aws-ses) — official AWS SDK/SigV4 native transport with temporary/rotating credential support, plus offline fixture and an explicit legacy proxy boundary. - Native SMTP (
SmtpDriver) — Pure async Lettre transport with implicit TLS on port 465 and mandatory STARTTLS on every other port. - Memory & MailTrap (
MemoryDriver,MailTrap) — Zero-I/O in-memory harness with fluent assertions; it is not the hosted Mailtrap service, whose transport v13 removed. - Your own transport — implement
MailDriver; see Writing your own mail transport. - Log (
LogDriver) — Metadata-only terminal and file logging (storage/logs/mail.log): a timestamp, attachment count and scheduling flag, never recipients, subject or body.
- Resend (
- 🔀 Typed Circuit Breaker & Automatic Failover (
FailoverDriver): Fails over only for transport, HTTP 5xx, provider rate-limit, or transient SMTP failures; permanent message/configuration/provider rejection stays on the original error path.SuppressionUnavailableandAttachmentInspectionUnavailableareTransient(retry later) but never failover-eligible. A fallback that returns such an error ends the chain with it; when every driver fails transiently, the result is aTransienterror, orRateLimitedwith the boundedRetry-Afterwhen the last driver was rate limited. Every attempt keeps the caller’s tenant context or delivery ID, so a wrappedTenantMailResolverselects the tenant’s driver. Structured tracing exposes bounded decision fields without provider bodies. - 🏢 Auth-bound Multi-Tenancy Resolver (
TenantMailResolver): Select isolated in-process drivers directly from a trusted CoreTenantContext; registry failures and invalid IDs fail closed. - 📎 Bounded Attachments & Inline CID Assets: The shared pre-flight contract caps count and byte size, validates safe basenames/MIME/CID metadata and requires every unique inline CID to be referenced by HTML. Resend, native SES, the SES bearer proxy and SMTP serialize the same owned-byte model, and SendPulse its non-inline attachments; transports copy or Base64-encode as required.
- 🔬 Opt-in Attachment Inspection (
AttachmentInspectionGuard): A strict bounded local policy rejects executable magic, spoofed known types, active PDF/SVG, secrets and unsafe text links before transport. Checks follow the case-insensitive declared type, the filename extension and the content signature together, never the declared type alone. A staticAttachmentInspectoradapter boundary supports an independently operated production scanner. - 🚫 Durable Recipient Suppression (
sqlite, v13postgres):SuppressionGuardchecks manual, hard-bounce and spam-complaint state before transport. The SQLite store binds verified provider/event identities, detects conflicting replay, enforces immutable quotas transactionally and survives restart or multiple local processes; the v13 PostgreSQL store shares that state across hosts. - 📊 Secret-Minimized Delivery Observability:
ObservedMailDriverrecords only a bounded provider label, terminal outcome, latency, attachment count and scheduling/tenant booleans through a non-failing static observer. - ⏰ Durable Scheduling (
.send_at(),.send_in()): SQLite and Redis queues persist schedules for up to 366 days and never claim early; direct Resend delivery uses provider scheduling and rejects a schedule beyond its documented limit (30 days ahead) withConfigErrorbefore any request. Real SMTP, SendPulse, Log and SES paths reject future direct delivery and must use a durable queue; offline fixtures may retain the timestamp for assertions. - 🕵️ Outbound Phishing & Homograph URL Interceptor (
.validate_security()): Pre-flight detection of mixed-script Unicode IDN spoofed domains (pаypal.comwith Cyrillic characters), checked per DNS label of the link host and user-info only, as the text reads and as a browser resolves the link (tabs and newlines removed,\read as/for HTTP(S),https:hostand scheme-relative\\hostforms, percent-encoded and A-label hosts), so single-script IDNs such asπαράδειγμα.grorпример.comand non-Latin query text are allowed while all-lookalike Cyrillic labels under a non-Cyrillic TLD are rejected, and dangerous URI schemes (javascript:,data:text/html). - 📜 RFC 8058 One-Click List-Unsubscribe: Automatic compliant header injection (
List-Unsubscribe, plusList-Unsubscribe-Post: List-Unsubscribe=One-Clickonly for an HTTPS unsubscribe URL, as RFC 8058 requires). The unsubscribe email must be one bare address and the URL may not contain whitespace,<,>or", so neither value can add another header entry. - 🔤 Automatic Plain-Text Fallback: Automatic HTML-to-plain-text conversion without manual duplication; an absolute HTTP(S) link keeps its target as
label <URL>unless the label already is the URL. - 🔒 Outbound DLP Secret Scanner: Proactive credential masking (whole-token
AKIA/ASIAAWS access key IDs, passwords, API tokens, bearer tokens and PEM private-key blocks of any<label>PRIVATE KEYtype, including OpenSSH, EC and encrypted keys) before emails leave your server. - 📦 Async Background Worker Queues: Native non-blocking dispatch via
rullst-core::queue. - 🧪 Explicit offline provider mode: empty or
mock_*credentials selectDeliveryMode::OfflineMock, never perform network I/O, and are inspectable throughOfflineMailMock. - 🛠️ Safe CLI Scaffolding: Generates registered facade-based Welcome, Password Reset, OTP, Invoice, custom, bounded payment-receipt, and explicit D+1/D+3/D+7 dunning mailables, refusing unsafe names/collisions and escaping dynamic HTML.
- 🧾 Payment-Bound PDF Delivery: The opt-in
capital-invoicebridge accepts only Capital’s final evidence-boundPaidInvoice, attaches bounded HTML/PDF, applies pre-flight and preserves a stable key for the application outbox.
🚀 Quickstart
1. Composing and Sending an Email
use rullst_mail::{Mail, Message};
use chrono::{Utc, Duration};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let logo_bytes = b"replace with application-owned PNG bytes";
let message = Message::new()
.to("[email protected]")
.from("[email protected]")
.subject("Welcome to Rullst!")
.html(r#"
<h1>Welcome, Alice!</h1>
<p>Thanks for joining our platform.</p>
<img src="cid:app_logo" alt="Logo" />
"#)
.attach_cid("app_logo", "logo.png", logo_bytes.to_vec(), "image/png")
.attach_bytes("welcome_guide.pdf", b"%PDF-1.4...".to_vec(), "application/pdf")
.send_in(std::time::Duration::from_secs(60)) // Deliver in 1 minute
.unsubscribe_url("https://rullst.dev/unsub/alice");
// The mandatory pipeline validates and sanitizes before queueing or delivery.
Mail::send(message).await?;
Ok(())
}
For a schedule that survives process restarts, operate a built-in queue and keep its worker handle alive:
#![allow(unused)]
fn main() {
use rullst_core::queue::{Queue, Worker};
use rullst_mail::{register_mail_handler, Mail, Message};
async fn schedule() -> Result<(), Box<dyn std::error::Error>> {
let queue = Queue::sqlite("sqlite://storage/jobs.db").await?;
let mut worker = Worker::new(&queue).poll_interval(100);
register_mail_handler(&mut worker);
let worker_handle = worker.run()?;
let message = Message::new()
.to("[email protected]")
.subject("Scheduled update")
.text("Delivered after the durable due time")
.send_in(std::time::Duration::from_secs(60));
Mail::enqueue(&queue, message).await?;
// Keep `worker_handle` in application state; shut it down during graceful exit.
worker_handle.shutdown().await?;
Ok(())
}
}
Execution begins on the first worker poll after the UTC timestamp and remains at-least-once. Queue scheduling does not promise exact wall-clock execution, exactly-once provider delivery, or provider acceptance. Redis promotes scheduled jobs by its server clock, so the worker accepts a claimed job whose timestamp is at most 300 seconds ahead of the worker’s own clock and fails a claim that is earlier than that.
The queue has no handler-requested retry: any delivery error, including a
Transient or RateLimited provider failure and its Retry-After, marks the
mail job failed with the error text. Nothing is lost silently, but the job is
sent again only after Queue::retry_failed_job. Automate that for transient
failures, or deliver through an outbox with its own retry policy (as account
mail does), when provider blips must be retried without an operator.
Mail::enqueue, and Mail::send after Mail::init_queue, run the pre-flight
pipeline and apply the default sender before writing a rullst_mail_send job
whose JSON payload is a versioned envelope (schema version 1) holding the
optional tenant ID and the prepared message. Queued jobs store attachment
bytes as one base64 string per attachment. Workers still accept jobs written
with the earlier integer-array encoding, but an older worker cannot read the
base64 form, so upgrade workers before producers during a rolling deployment.
2. Resilient Multi-Driver Failover (Circuit Breaker)
#![allow(unused)]
fn main() {
use rullst_mail::drivers::{FailoverDriver, ResendDriver, SendPulseDriver};
use std::time::Duration;
fn build_failover() -> Result<FailoverDriver, rullst_mail::MailError> {
let primary = ResendDriver::try_new("re_...")?;
let fallback_1 = SendPulseDriver::try_new("sendpulse_api_key...")?;
let failover_driver = FailoverDriver::new(primary)
.with_fallback(fallback_1)
.with_threshold(3) // Trip circuit after 3 consecutive failures
.with_cooldown(Duration::from_secs(60)); // Cooldown for 60s
Ok(failover_driver)
}
}
3. Dynamic B2B Multi-Tenancy Routing
#![allow(unused)]
fn main() {
use rullst_core::security::TenantMembership;
use rullst_mail::{Message, ResendDriver, TenantMailResolver};
async fn send_tenant_message(
message: &Message,
) -> Result<(), Box<dyn std::error::Error>> {
let resolver = TenantMailResolver::new();
let membership = TenantMembership::try_new(["tenant_globex"])?;
let context = membership.select("tenant_globex")?;
// Register tenant-specific API credentials during application configuration.
resolver.register_for_context(
&context,
ResendDriver::try_new("re_globex...")?,
)?;
// The context must be derived from trusted authentication/membership state.
resolver.send_for_context(&context, &message).await?;
Ok(())
}
}
The registry is intentionally process-local. Durable encrypted credential storage, rotation, and distribution between instances remain application/deployment concerns.
4. Fast Unit & Integration Testing with MailTrap
#![allow(unused)]
fn main() {
use rullst_mail::{Mail, MailTrap, Message};
#[tokio::test]
async fn test_user_registration_email() {
Mail::set_driver(Box::new(MailTrap::driver()));
MailTrap::clear();
let msg = Message::new()
.to("[email protected]")
.subject("Welcome to Rullst!")
.html("<p>Please verify your email address.</p>")
.attach_bytes("terms.pdf", b"%PDF...".to_vec(), "application/pdf")
.unsubscribe_url("https://example.com/unsub/alice");
Mail::send_now(msg).await.unwrap();
// Fluent assertions
MailTrap::assert_sent_to("[email protected]")
.with_subject("Welcome to Rullst!")
.with_body_contains("Please verify your email")
.with_attachment_count(1)
.with_attachment_named("terms.pdf")
.with_unsubscribe_url("https://example.com/unsub/alice");
}
}
5. Scaffolding Mailables with CLI
# Generate Welcome & Onboarding email
cargo rullst make:mail WelcomeEmail --welcome
# Generate Time-limited Password Reset email
cargo rullst make:mail PasswordReset --reset
# Generate Two-Factor OTP code email
cargo rullst make:mail OtpVerification --otp
# Generate SaaS Invoice receipt email
cargo rullst make:mail InvoiceReceipt --invoice
# Generate a bounded commercial payment receipt (PaymentReceiptEmail)
cargo rullst make:mail-invoice
# Generate explicit D+1/D+3/D+7 payment-recovery stages
cargo rullst make:mail-dunning
The receipt template enables only the mailer feature. Its
international_receipt constructor records an application-confirmed payment
and states that it is not a tax authorization; it has no NFS-e branch (v13
removed NFS-e from Rullst). The dunning template does not infer due dates,
schedule itself, or mutate access. Both generated build paths run the
mandatory mail pre-flight and reject unsafe links; payment evidence, billing
state, scheduling, and policy remain application-owned.
For native payment-bound PDF delivery, enable rullst-mail/capital-invoice (or
umbrella rullst/capital-mail) and use PaidInvoiceDelivery::prepare, then
set the verified sender with .from(sender)? (13.0), which re-runs pre-flight.
It rejects non-final/mock evidence and recipient/amount/currency substitution.
Applications still reconcile webhooks and atomically claim the stable delivery
key; provider acceptance and exactly-once delivery are not promised.
6. Pre-Flight Deliverability & Disposable Email Filtering
Prevent sender quota waste and fake user signups with built-in deliverability checks and blocked temporary domains:
#![allow(unused)]
fn main() {
use rullst_mail::{is_disposable_email, validate_email_deliverability, Message};
// 1. Direct email address validation
assert!(validate_email_deliverability("[email protected]").is_ok());
assert!(is_disposable_email("[email protected]"));
// 2. Pre-flight check before dispatching
let msg = Message::new()
.to("[email protected]")
.subject("Welcome!");
if msg.is_disposable() {
eprintln!("Blocked disposable email address!");
}
}
7. Composing inspection, suppression, and observability
The wrappers use static dispatch and may be composed around any MailDriver.
Enable rullst-mail/sqlite (or umbrella rullst/mail-sqlite) for the durable
local suppression store:
#![allow(unused)]
fn main() {
use rullst_mail::{
AttachmentInspectionGuard, BoundedMailObserver, LocalAttachmentInspector,
MailDriver, MemoryDriver, Message, MutableSuppressionStore,
ObservedMailDriver, SqliteSuppressionStore, SuppressionEvent,
SuppressionGuard, SuppressionReason,
};
use std::time::{SystemTime, UNIX_EPOCH};
async fn deliver() -> Result<(), Box<dyn std::error::Error>> {
let store = SqliteSuppressionStore::connect(
"sqlite://storage/mail-suppressions.sqlite",
100_000,
500_000,
).await?;
// Only record events after the provider-specific signature was authenticated.
let observed_at = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
store.record(SuppressionEvent::try_new(
"resend",
"verified-event-id",
"[email protected]",
SuppressionReason::HardBounce,
observed_at,
)?).await?;
let (transport, _) = MemoryDriver::isolated();
let inspected = AttachmentInspectionGuard::new(
transport,
LocalAttachmentInspector::strict(),
);
let suppressed = SuppressionGuard::new(inspected, store);
let observer = BoundedMailObserver::new(10_000)?;
let driver = ObservedMailDriver::try_new("memory", suppressed, observer)?;
driver.send(&Message::new()
.to("[email protected]")
.subject("Bounded delivery")
.text("Hello"))
.await?;
Ok(())
}
}
SuppressionEvent does not verify a webhook signature: a provider-specific
adapter must authenticate the exact event first. SQLite is shared durable local
state, not multi-host replication or encrypted storage. Keep replay IDs at least
as long as every provider’s redelivery window. The local attachment inspector is
a bounded heuristic, not antivirus, sandbox execution, recursive archive
inspection or content disarm. The default observer is bounded and process-local;
the host owns any external metrics/tracing sink, retention and alerts.
8. Authenticated open/click tracking primitives
Generate versioned, purpose-bound HMAC-SHA256 tracking tokens with a mandatory
32-byte secret and bounded validity. HMAC authenticates but does not encrypt:
recipient and target URL remain base64-readable in the current token.
Message tracking builders sign the bare address the pipeline delivers to
([email protected] for Alice <[email protected]>), so set to first. The
application owns consent, minimization, retention, redirects and applicable
privacy-law decisions.
#![allow(unused)]
fn main() {
use rullst_mail::{TrackingEngine, TrackingVerifier, PIXEL_1X1_GIF, Message};
use std::time::Duration;
fn tracking_example() -> Result<(), Box<dyn std::error::Error>> {
let secret = b"replace-with-32-or-more-random-key-bytes";
let now_unix_seconds = 1_800_000_000;
// Fluent open & click tracking injection
let tracked_msg = Message::new()
.to("[email protected]")
.subject("Monthly Newsletter")
.html("<p>Check out our <a href=\"https://rullst.dev/pricing\">pricing</a>.</p>")
.try_with_open_tracking("https://app.com", secret, "campaign_2026")?
.try_with_click_tracking("https://app.com", secret)?;
let token = TrackingEngine::try_generate_open_token(
secret,
"[email protected]",
"campaign_2026",
now_unix_seconds,
)?;
// Default verification enforces a 30-day TTL.
let event = TrackingEngine::verify_open_token(secret, &token)?;
println!("Email opened by {} for campaign {}", event.email, event.campaign_id);
// Endpoints needing single-consumption semantics can reject replay explicitly.
let verifier = TrackingVerifier::new(Duration::from_secs(24 * 60 * 60), 100_000)?;
let event = verifier.verify_open_once(secret, &token, now_unix_seconds)?;
let _ = (tracked_msg, event, PIXEL_1X1_GIF);
Ok(())
}
}
9. Transactional Test Fixtures with MailFactory
Quickly generate standard transactional emails for local preview and testing:
#![allow(unused)]
fn main() {
use rullst_mail::MailFactory;
let welcome_msg = MailFactory::fake_welcome("[email protected]", "Alice", "My SaaS App");
let reset_msg = MailFactory::fake_password_reset("[email protected]", "https://app.com/reset?token=xyz", 15);
let otp_msg = MailFactory::fake_otp("[email protected]", "492015", 5);
let invoice_msg = MailFactory::fake_invoice("[email protected]", "INV-2026-001", 9900, "USD");
let alert_msg = MailFactory::fake_security_alert("[email protected]", "Unrecognized Login", "198.51.100.1", "Chrome / macOS");
}
10. Native AWS SES v2 with SigV4
Enable the opt-in official SDK transport:
[dependencies]
rullst-mail = { version = "=13.0.0-alpha.4", features = ["aws-ses"] }
aws-config = "1.11"
MAIL_DRIVER=ses selects native mode when both AWS_ACCESS_KEY_ID and
AWS_SECRET_ACCESS_KEY exist. AWS_SESSION_TOKEN is accepted for temporary
credentials. Without those variables, the existing empty/mock_* token rule
selects the offline fixture; a real AWS_SES_BEARER_TOKEN is usable only with
an explicit trusted proxy URL. The proxy receives the SES v2 SendEmail JSON
shape, including every attachment and inline CID asset as
Content.Simple.Attachments (Base64 RawContent, FileName, ContentType,
ContentDisposition and ContentId). It is checked against the same SES field
limits and 40 MiB encoded estimate as native mode before any request, and
failures return MailError::ValidationError. The proxy must forward
attachments or reject the request; Rullst never drops them.
SES does not support SMTPUTF8, so native and proxy requests carry 7-bit ASCII
addresses: an internationalized domain becomes its IDNA A-label
(maria@bücher.de is sent as [email protected]) and a non-ASCII sender
display name becomes RFC 2047 encoded-words (=?UTF-8?B?...?=), while an
all-ASCII sender is sent unchanged. A non-ASCII local part cannot be expressed
and fails before any request with MailError::ValidationError. (v13)
Long-running services should inject a refreshing credential provider or a caller-built SDK config instead of freezing credentials:
This snippet uses the application’s direct aws-config dependency shown
above, which Rullst does not re-export, so the book doctests do not compile it
(rust,ignore):
use rullst_mail::{AwsSesDriver, MailDriver, Message, aws_ses_sdk};
async fn deliver() -> Result<(), Box<dyn std::error::Error>> {
let shared = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let config = aws_ses_sdk::Config::new(&shared);
let driver = AwsSesDriver::from_native_config(config)?;
driver.send(&Message::new()
.to("[email protected]")
.from("[email protected]")
.subject("Signed by AWS SigV4")
.text("Hello from Rullst"))
.await?;
Ok(())
}
The application still owns AWS identity/domain verification, sandbox exit, IAM
least privilege, quotas, reputation, bounce/complaint handling and monitoring.
A successful MessageId is provider acceptance, not proof of inbox delivery.
The native adapter rejects SES field limits and an encoded message estimate
over 40 MiB before network I/O; provider 429 responses preserve a bounded
delta-seconds Retry-After for failover/retry policy.
⚙️ Configuration (Rullst.toml or Environment Variables)
[mail]
driver = "resend" # "log" | "memory" | "smtp" | "resend" | "ses" | "sendpulse"
from = "Acme <[email protected]>" # default sender for messages without `from`
The Mail facade reads each variable below from the process environment first
and then from the project’s ./.env, which never overrides the environment,
exactly as Server reads DATABASE_URL; driver and from in Rullst.toml
come last. LogDriver reads MAIL_LOG_PATH the same way. The staging and
production check uses the Server environment precedence (RULLST_ENV, then
APP_ENV, from the environment and then .env, then [app] env). A
malformed .env fails with MailError::ConfigError without echoing its
content.
The settings are read on every facade call, but the driver built from them is
reused while they stay the same, so its connection pools and native SES client
survive across messages and queue jobs. A changed setting builds a new driver
for the next message, Mail::reset_driver() drops the reused one, and
MAIL_DRIVER=memory still builds a fresh store per message. (v13)
Environment variables:
MAIL_DRIVER: Select active driver (log,memory,smtp,resend,sesor its aliasaws_ses,sendpulse). The values of the transports v13 removed (sendgrid,postmark,mailjet,mailjet-sandbox,mailtrap,mailtrap-sandbox,azure-acs) fail withMailError::ConfigError, which names the driver and the migration row, and never fall back to another driver. When neither it nor[mail] driveris set, development and test fall back tolog, while staging and production (RULLST_ENV,APP_ENVor[app] env, as above) returnMailError::ConfigErrorinstead of logging mail that is never delivered. Selectlogexplicitly to keep metadata-only logging there.MAIL_FROM: Default sender (v13) forMailfacade messages that set nofrom, such as generated mailables. It takes precedence over[mail] from; an explicitfromon the message always wins. Use one address orName <address>that your provider account has verified. An invalid value fails every facade send withMailError::ConfigError; callMail::default_sender()at startup to fail fast. Drivers used directly do not read it; the other settings below are read by the facade when it builds a driver, andMAIL_LOG_PATHbyLogDriveritself.RESEND_API_KEY: API key for Resend.SENDPULSE_API_KEY: Static API key for SendPulse.AWS_REGION: Region used by native SigV4 signing or SES proxy/mock metadata.AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY: Select native SES when theaws-sesfeature is enabled; both must be present.AWS_SESSION_TOKEN: Optional temporary-credential session token.AWS_SES_BEARER_TOKEN: Bearer token for an explicit trusted proxy; it is never sent to AWS as a substitute for SigV4.AWS_SES_ENDPOINT: Native SDK base endpoint or complete proxy send URL; HTTPS is required except for loopback integration tests.MAIL_HOST,MAIL_PORT,MAIL_USERNAME,MAIL_PASSWORD: SMTP credentials.MAIL_PORTdefaults to 25 only when unset or empty; any other value must be an integer from 1 to 65535, or the facade returnsMailError::ConfigError.MAIL_LOG_PATH: Path for log file (default:storage/logs/mail.log).
For Resend, SendPulse and the SES fixture/proxy,
an empty credential or one beginning with mock_ selects the deterministic
offline fallback. Use driver.delivery_mode() and
OfflineMailMock::deliveries() to assert this explicitly in tests. The
process-wide capture keeps only the newest 1,000 deliveries and at most 64 MiB
of their subject, body and attachment bytes, and the first capture in a process
logs a mail.offline_mock.active warning, because an empty production secret
also selects this fallback.
Every real transport needs a sender that the provider account has verified:
the message’s from, or, for Mail facade sends, the MAIL_FROM /
[mail] from default. Without either, delivery fails with
MailError::ConfigError before any request, and the error names both
settings. Transports never invent a sender.
SMTP selects the offline fallback only explicitly: an empty or mock_*
MAIL_HOST, or a mock_* username or password. A real host without
credentials is an unauthenticated relay and receives real delivery, so
MAIL_DRIVER=smtp without MAIL_HOST sends to 127.0.0.1:25. A username
without a password, or the reverse (blank values count as missing), returns
MailError::ConfigError instead of falling back to the mock.
Port 465 uses implicit TLS (SMTPS); every other port, including the facade’s
default 25 and the usual submission port 587, requires STARTTLS. Plaintext is
never used: a server that does not offer STARTTLS, or presents a certificate
that is not valid for MAIL_HOST, fails with a typed transport error, so a
local relay needs a certificate valid for the configured host name.
Scope and product boundaries
rullst-mail is a transactional-delivery library, not a marketing CRM or a
claim that third-party delivery infrastructure is unnecessary.
Implemented building blocks include:
- typed message construction and escaped generated templates;
- explicit SMTP, Resend, SendPulse, log and memory drivers, plus the opt-in official-SDK SES v2 transport and bounded SES proxy/mock adapter;
- deterministic offline mode for empty or
mock_*provider credentials; - an in-memory
MailTrapandMailFactoryfixtures; - bounded retry/failover helpers, tenant-driver resolution, attachments, provider-specific scheduling fields, and durable SQLite/Redis due times;
- opt-in attachment inspection, process-local, shared-local SQLite or (v13) shared PostgreSQL suppression, and content-minimized local delivery observations;
- HMAC-authenticated (not encrypted) tracking tokens with expiry/replay helpers, URL checks, and bounded secret-redaction heuristics.
These components do not provide deliverability, sender-domain reputation, legal consent, unsubscribe policy, durable campaign orchestration, a visual marketing editor, or a production inbox. Provider acceptance is not proof of delivery. Tracking pixels/links have privacy and consent implications that the application must evaluate for each jurisdiction and use case.
Choose a delivery provider and operational policy based on measured volume, region, data processing terms, bounce/complaint handling, retention, cost, and failover tests. Rullst publishes no universal latency, price, or feature comparison against commercial platforms.
Recipients are parsed once by the pre-flight pipeline. It accepts one bare
address, <address> or Name <address> (the name may be quoted), and hands the
bare address to suppression, the disposable-domain check and every transport,
so a display name is not delivered. Lists, groups, comments, quoted local parts,
domain literals and malformed brackets are rejected with
MailError::ValidationError. Suppression events and lookups use the same
parser; anything it rejects fails closed. Suppression keys compare the domain
case-insensitively and key an internationalized domain by its IDNA A-label
(bücher.de and xn--bcher-kva.de share one entry); a non-ASCII local part
cannot be keyed, so SuppressionGuard rejects it with ValidationError rather
than reporting SuppressionUnavailable.
The pre-flight pipeline rejects a subject over 2 KiB, or an HTML or plain-text
body over 2 MiB each, with MailError::ValidationError before any content scan.
Oversized content is rejected, never truncated. The link, homograph and
secret-redaction scans are single forward passes, so their cost grows linearly
with the bounded body. They still run on the calling task; bound user-supplied
text at the request edge as well.
The local inspector chooses its checks from the declared MIME type (compared
case-insensitively), the filename extension and the content signature
together. Both policies reject executable magic, executable or script-host
extensions (.exe, .bat, .cmd, .ps1, .vbs, .js, .hta, .lnk and
similar), SVG by type, extension or content, active PDF content wherever a
%PDF- header appears in the first KiB, and a declared type that disagrees
with a known extension or signature. strict() also rejects HTML extensions,
HTML/script markup or javascript:/vbscript: URIs, unknown extensions, any
declared type it does not inspect other than application/octet-stream (so a
text/html attachment named invoice.txt is rejected), and opaque formats;
allowing_opaque() still accepts HTML and other opaque content. PDF names written with #xx escapes or inside compressed streams are
not decoded.
Markup is classified the way a browser parses it, not by its first bytes: a
document opening with < is SVG or active (X)HTML by its root element after
any XML declaration, processing instruction, comment or DOCTYPE, matched after
an optional prefix: (<s:svg>, <h:html>), and by the SVG or XHTML
namespace URI anywhere in it, also when written with character references.
Markup that declares DTD entities, which can assemble a namespace URI from
pieces, is treated as SVG unless its root is html, and a UTF-16 document is
read by its byte-order mark. (v13)
Attachment limits are 32 items, 20 MiB per item and 25 MiB of raw bytes in aggregate before transport encoding. Provider/account limits can be lower. The base pipeline validates metadata but treats bytes as opaque. The opt-in local inspector recognizes only its documented bounded formats and heuristics; use a production scanner adapter when malware, archive, sandbox or CDR policy is required.
Account mail (since 12.1)
Version note: this contract was introduced in 12.1 and continues in v13. The v13 line removed the Azure Communication Services transport (its section below is kept for 12.x readers), together with the SendGrid, Postmark, Mailjet and Mailtrap transports; they remain in the 12.x line, which reached end of life on 16 October 2026. Account mail in v13 is delivered through Resend, AWS SES, SendPulse, SMTP or an application-owned
MailDriver; see the migration guide row “Mail providers removed” and Writing your own mail transport.
This is an opt-in contract. Updating a dependency does not migrate an application’s account table, replace its session middleware, configure a sender or deploy a worker. The 12.x release record lists its first publication.
Boundaries
rullst-auth::recovery owns the authoritative account registry, password reset,
revocable opaque sessions and encrypted transactional outbox. rullst-mail owns
message construction, mandatory preparation and transport. The small
rullst::account_mail bridge composes them without coupling Auth to a provider
or introducing another publishable crate.
Enable account-mail-postgres or account-mail-sqlite on rullst, or the
corresponding recovery-* feature on Auth when supplying a different worker.
These features do not enable the general ORM. Features remain additive. In v13
rullst has no default features; on 12.x, disable the umbrella defaults when a
backend-exclusive dependency graph is required.
Adoption sequence
- Obtain two independent persistent random 32-byte application secrets from a
secret manager.
RecoverySecrets::newseparates keyed token/email digests from AES-256-GCM outbox/address encryption. Never use the fixture keys from tests. There is no automatic key rotation: plan a reviewed migration and backup restoration with the same secrets. - Connect
SqlRecoveryStore, then explicitly runmigrate()during deployment. PostgreSQL and SQLite share the fixed authoritative schema. Namespace stores by application/tenant with separate databases and secrets; a public request cannot select a tenant or supply a database URL. SQLite filesystem paths must be URL-encoded; usesqlite:PATH?mode=rwcwithout an authority for Windows drive letters. A remote PostgreSQL host always usessslmode=verify-full(certificate and hostname verification), whatever the URL requests; supplysslrootcertfor a private CA. Loopback hosts and Unix sockets keep the configured mode. Query options other thansslmode,sslrootcert,sslcert,sslkey,host,hostaddr,port,dbname,userandpasswordfail withConfigurationbefore connecting. During shutdown, stop the request/worker tasks and awaitclose()before moving or deleting a database; it closes the connection pool shared by every store clone. - Register new accounts with
register_account_with_locale. Account creation and the encrypted welcome notice commit together. The supported recorded locales are English, Brazilian Portuguese and Spanish; the worker supplies a deterministic fallback. An email or subject that is already registered returnsInvalidAction(without naming which), not theStorageerror of a database failure; present it without confirming that the email exists. Existing accounts require an application migration; there is no automatic import of arbitrary password tables. - Use
authenticate,create_sessionandverify_sessionfor this registry. Passwords contain at least 12 characters and at most 72 bytes, the Argon2 input limit. Registration, reset andauthenticatereject longer input withInvalidInputbefore any account lookup, so the outcome does not reveal whether an email is registered; unknown accounts still perform Argon2 work. Verify the opaque session on every authenticated request. Set it in a Secure, HttpOnly, appropriately SameSite cookie. Userevoke_sessionfor logout. Legacy encrypted-only session cookies do not gain revocation implicitly. - A protected, independently rate-limited request endpoint calls
request_password_reset(email, trusted_now). Return the same generic acknowledgement for unknown, throttled and accepted requests. No mail provider is contacted inline. Database failures require a generic service response. - A separate bounded worker calls
deliver_next_account_mailwith anAccountMailConfigcontaining the server-owned HTTPS origin, reset endpoint, sender, application name and fallback locale. The endpoint cannot contain a query or fragment. The provider must have a verified sending domain. - Submit the new password and opaque code to a CSRF-protected consume endpoint
using
complete_password_reset. Never include the password in a URL. On success, direct the member to login; no automatic login is performed.
The reset page must set Cache-Control: no-store and Referrer-Policy: no-referrer, remove the action query from browser history, avoid third-party
page resources, and ensure proxy/access logs omit the query string. The SDK
cannot configure a deployed proxy. Apply separate ingress limits to login,
request and consume routes, and test response timing in the deployed system.
The store enforces a 250 ms minimum request duration, but this alone cannot hide
arbitrary database delays or establish indistinguishability under outages.
Reset and delivery guarantees
- Reset codes use 256 bits from the operating system CSPRNG and expire after 20 minutes. The credential table retains only a purpose-separated keyed digest. A replacement request invalidates previous unused codes.
- Password replacement, consumption, sibling invalidation, session revocation and creation of a password-changed notice share one database transaction. Failure to persist the outbox rolls the password change back.
- Recovery requests have a three-per-account/15-minute limit and a global
120-per-minute ceiling. Consumption looks the code up before any Argon2 work,
so unknown, expired or consumed codes cost one indexed read. Each code has its
own budget of 10 attempts per minute, counting attempts in flight; only a
failed lookup keeps its unit. An exhausted code receives
RecoveryError::Throttled(map it to a generic “try again later”), while other members’ codes are never blocked. The budget is process-local and bounded to 10,000 recent codes. These are bounded defaults, not a substitute for per-client/distributed ingress controls. - The outbox encrypts recipient and reset code, with per-record authenticated encryption. The recovery credential remains digest-only; the delivery copy is decryptable using the application encryption key until expiry/terminal cleanup. Retention is bounded at 10,000 records, with six attempts, 60-second leases and exponential retry delay. When the outbox is full, the oldest delivered or failed (ciphertext-free) records are evicted first, so only pending and leased notices can block registration or silently drop a reset request. Old workers cannot acknowledge a newer lease.
- Reset emails render an absolute UTC expiry, keeping the body identical across retries. Stable delivery IDs reach Resend through observation, inspection, suppression, resolver and failover wrappers. Other transports remain at-least-once. Switching providers or crashing after acceptance can duplicate mail. Resend’s idempotency contract has its own retention window; this is not an exactly-once inbox guarantee.
Message content and feedback
ActionLink validates the application’s exact configured origin. AccountMail
provides deterministic localized welcome, reset, password-changed, verification,
new-device, security-alert, address-change, closure and export notices. These
additional templates do not implement the corresponding identity transactions.
MailPurpose identifies their purpose; a template is not a marketing consent
record or a legal-compliance certificate.
MAIL-001/RULLST-005 is fixed in the mandatory pipeline: bounded opaque token=
query values inside safe body URLs survive preparation, including HTML-escaped
query separators and plain-text links wrapped in <…>, (…) or Markdown
[label](…) or followed by sentence punctuation. Other credentials and non-URL
token text remain redacted.
Message, action links and recovery notices omit sensitive content from Debug;
LogDriver emits delivery metadata only. Do not log values explicitly exposed
for delivery by expose_url, expose or into_message.
ResendFeedbackVerifier authenticates the exact raw body with Svix v1 HMAC and
five-minute freshness. Reject duplicate signature headers at the HTTP boundary.
It classifies email.delivered, email.delivery_delayed, email.bounced,
email.complained and email.failed; subscribe the webhook to those. Another
authentic event type (such as email.sent or email.opened) returns the v13
MailFeedbackError::UnsupportedEvent, which an adapter can acknowledge without
acting on it, while malformed or unclassifiable bounce payloads stay
InvalidPayload.
Use its suppression_event() with MutableSuppressionStore::record, then place
SuppressionGuard around the worker transport. Permanent bounces and complaints
suppress delivery; transient failures do not. A durable suppression store is
required across process restarts. The SDK provides a SQLite suppression adapter;
other production deployments supply their own SuppressionStore. Verified
feedback does not automatically mutate an unrelated account registry.
Security templates add no tracking or marketing unsubscribe dependency. Disable tracking at the provider/domain/account level as well; for example, the 12.x Postmark transport’s server settings can override the message flag. Marketing consent, legal basis, preference withdrawal and retention are application responsibilities; enabling Mail cannot establish GDPR/LGPD compliance.
Azure Communication Services (12.x only)
This transport was removed in v13. On 12.x, AzureCommunicationDriver supports the ACS Email 2023-03-31 REST
contract. Set MAIL_DRIVER=azure-acs and
AZURE_COMMUNICATION_EMAIL_ENDPOINT=https://RESOURCE.communication.azure.com.
In Azure Container Apps, AzureManagedIdentity uses the platform-injected
loopback IDENTITY_ENDPOINT/IDENTITY_HEADER and optional user-assigned
AZURE_CLIENT_ID for the Communication Services resource. The endpoint must be
plain HTTP on a literal loopback IP or the exact name localhost, which the
identity client pins to 127.0.0.1/::1 instead of resolving. The host must
grant the identity email-sending permission and verify its domain/sender. A
credential reuses its token until five minutes before expires_on; identity
endpoint throttling (HTTP 429) and 5xx outages are RateLimited/Transient,
while other refusals remain a permanent ConfigError. The Mail facade builds
a new credential per send, so reuse applies to a long-lived driver instance.
The driver disables engagement tracking, bounds payload/response sizes,
validates the operation-polling origin, and accepts only a terminal successful
operation as success. A pending operation after the bounded wait needs
reconciliation; ACS retries have at-least-once semantics. An empty or mock_*
endpoint/credential is deterministic offline behavior, not Azure acceptance.
Live Managed Identity/ACS sending still requires deployment acceptance.
See the official REST contract
and Container Apps identity contract.
Remaining proposal work
The examples proposal is broader than welcome/password recovery. A Redis
identity registry, durable email-verification/address-change transactions,
marketing consent policy, and an authorized/audited reset-resend administration
workflow are separate work. Existing Mail observers and outbox_snapshot()
provide minimized application diagnostics; this change does not expose a global
mailbox browser, reset codes or administrative resend endpoint.
Local contracts cover mandatory preparation, localized mock delivery, SQLite restart/encryption/fencing, concurrent single use, rollback, session revocation, signed feedback and PostgreSQL recovery. CI runs the PostgreSQL contract against a pinned disposable image. Provider fixtures do not establish actual delivery, reputation, merchant readiness or regulatory compliance.
Rullst Auth
rullst-auth provides Argon2id password hashing, versioned AES-GCM cookie sessions,
role-based authorization middleware, WebAuthn/passkey ceremony verification, and
an opt-in application JWT policy.
Application JWTs are enabled with jwt. The sqlite feature also enables JWT
and adds durable shared JWT revocation plus passkey device state. OAuth2/OIDC
providers remain a separate trust boundary enabled with oauth, which
re-exports rullst-connect.
Passwords
Use the asynchronous functions inside HTTP handlers so Argon2 work runs on Tokio’s blocking pool:
#![allow(unused)]
fn main() {
use rullst_auth::{AuthError, hash_password_async, verify_password_async};
async fn verify_login(password: String) -> Result<bool, AuthError> {
let hash = hash_password_async(password.clone()).await?;
Ok(verify_password_async(password, hash).await)
}
}
Passwords longer than 72 bytes are rejected. needs_rehash compares the algorithm, version,
memory, iteration, and parallelism parameters. The optional SqlRecoveryStore applies
the same 72-byte bound to registration, password reset and authenticate; overlong input
returns RecoveryError::InvalidInput before any account lookup, for registered and
unknown emails alike.
Encrypted sessions
make_login_cookie, make_login_cookie_with_epoch and their decoders
decrypt_session and decrypt_session_claims use a versioned AES-256-GCM
envelope with authenticated metadata and an operating-system nonce. APP_KEY must contain at least 32 bytes,
must not be a documented placeholder, and must satisfy the entropy check.
The cookie helpers select Secure from RULLST_ENV, then APP_ENV, then .env, then
Rullst.toml. When either process variable is set, .env is not read for that decision. A
malformed .env produces a fixed AuthError::General message naming only the failing entry
number; file content, including values after an unclosed quote, never appears in the error.
A malformed Rullst.toml likewise reports only its line and column.
Since 13.0.0-alpha.2 the Core security baseline that Server mounts also adds
Secure to these rullst_session cookies, in any environment, when the request
arrived over HTTPS: an https request URI, or X-Forwarded-Proto: https from a
proxy that [security] trusted_proxies lists with trust_forwarded_proto = true
(the same evidence Nexus Basic Auth accepts). It never removes Secure.
#![allow(unused)]
fn main() {
use rullst_auth::{AuthError, decrypt_session, get_app_key, make_login_cookie};
fn round_trip(user_id: i32) -> Result<i32, AuthError> {
let cookie = make_login_cookie(user_id)?;
let token = cookie
.split(';')
.next()
.and_then(|part| part.split_once('='))
.map(|(_, value)| value)
.ok_or_else(|| AuthError::General("session cookie is malformed".to_string()))?;
decrypt_session(token, &get_app_key()?)
}
}
What the stateless cookie can and cannot do
The server keeps no list of sessions. A rullst_session token holds only the
user id, its expiry and, for the epoch cookies below, a session epoch, sealed
with APP_KEY:
- It proves the server issued the cookie, expires on its own, and stops working once the application changes the user’s stored session epoch.
- It cannot end one device’s session while keeping the others, list active
sessions, or be revoked without that stored epoch.
make_logout_cookieonly asks the browser to delete its copy: a copied cookie stays valid until it expires (30 days formake_login_cookie), the epoch changes, orAPP_KEYis rotated, which signs out every user.
For server-side sessions that can be listed and revoked one at a time, use
recovery::SqlRecoveryStore (recovery-sqlite or recovery-postgres; see
active-session management).
Revocable cookies: the session epoch
Store a session epoch per user, an integer starting at 0, put it in the cookie at sign-in and compare it on every request:
#![allow(unused)]
fn main() {
use rullst_auth::{
AuthError, DEFAULT_SESSION_MAX_AGE, decrypt_session_claims, get_app_key,
make_login_cookie_with_epoch,
};
/// At sign-in: the cookie carries the user's current epoch.
fn sign_in(user_id: i32, stored_epoch: u64) -> Result<String, AuthError> {
make_login_cookie_with_epoch(user_id, stored_epoch, DEFAULT_SESSION_MAX_AGE)
}
/// On every request: `token` is the value `extract_session_cookie` returns and
/// `stored_epoch` loads the user's current epoch.
fn signed_in_user(
token: &str,
stored_epoch: impl Fn(i32) -> Option<u64>,
) -> Result<i32, AuthError> {
let claims = decrypt_session_claims(token, &get_app_key()?)?;
let current = stored_epoch(claims.user_id).ok_or(AuthError::SessionRevoked)?;
claims.verify_epoch(current) // `AuthError::SessionRevoked` on a mismatch
}
}
To sign out every device, increment the stored epoch in one statement
(UPDATE users SET session_epoch = session_epoch + 1 WHERE id = ?) when the
user chooses “sign out everywhere”, after a password change or reset, and when
the account is deactivated or deleted. Issue a new cookie with the new epoch
if the current browser should stay signed in.
max_ageis counted in whole seconds, from 1 second to 400 days (MAX_SESSION_MAX_AGE), and sets both the token expiry and the cookieMax-Age. A shorter lifetime limits how long a copied cookie works if nobody revokes it.decrypt_session_claimsalso accepts cookies made bymake_login_cookieandencrypt_session, as epoch 0: start stored epochs at 0 and existing sessions keep working, until the first bump revokes them.decrypt_sessionrejects epoch cookies, so a route that still calls it fails closed instead of skipping the epoch check.rullst-authhas no session middleware: the application loads the stored epoch, which costs one lookup per request.
cargo rullst auth and the SaaS starter generate this wiring (the LMS example has it too): a
users.session_epoch column, epoch cookies at sign-in and registration, the
check in auth_middleware, User::revoke_sessions, a
POST /logout/everywhere route, and a password change and a password reset
that increment the epoch (below).
Password reset tokens
For a users table the application owns, PasswordResetToken is the emailed
secret of a reset link (new in v13): 32 bytes from the operating system’s
CSPRNG, written as 64 lowercase hex characters. Email
expose() and store only digest(), a domain-separated SHA-256:
#![allow(unused)]
fn main() {
use rullst_auth::{
AuthError, PasswordResetToken, password_reset_digests_match, password_reset_token_digest,
};
// On a reset request: email `token.expose()` in a link such as
// https://app.example.com/password/reset?token=…, and store the digest with
// the account id and an expiry.
let token = PasswordResetToken::generate()?;
let stored = token.digest();
// When the link comes back: a malformed value has no digest, so it never
// reaches the database; look the digest up and compare in constant time.
let submitted = password_reset_token_digest(token.expose()).ok_or(AuthError::InvalidCredentials)?;
assert!(password_reset_digests_match(&stored, &submitted));
Ok::<(), AuthError>(())
}
Expiry, single use and one live token per account belong to the
application’s store. The generated password_reset_tokens table keeps one
token per account (its primary key), expires it after
RESET_TOKEN_LIFETIME_MINUTES (30 as generated; 10 minutes to 24 hours, see
Tutorial 11)
and deletes
it in the transaction that stores the new password hash and increments the
session epoch. A password change keeps the browser that made it signed in with
a cookie at the new epoch; a reset signs out every device and redirects to the
login page. See Tutorial 11
for the generated routes and their answers.
Optional v13 account features
With recovery-sqlite or recovery-postgres, recovery::SqlRecoveryStore
provides an authoritative account registry, password recovery and revocable
opaque sessions, including active-session management.
email-login-sqlite/email-login-postgres add
single-use email login, and
api-tokens-sqlite/api-tokens-postgres add
scoped API tokens. The facade features are
auth-sessions-*, auth-email-login-* and auth-api-tokens-*. These
v13 additions are opt-in and do not enable JWT or OAuth.
WebAuthn/passkeys
PasskeyAuth validates exact RP origin and ID binding, one-time expiring challenges
(challenge_ttl_seconds from 1 to 86,400; PasskeyAuth::new rejects other values),
client-data ceremony type, user-presence/user-verification flags, ES256 COSE keys, P-256 points,
credential IDs, signatures, and monotonic counters. Only none attestation is advertised and
accepted. With sqlite, SqlitePasskeyStore supplies bounded file-backed
registration, listing, renaming, revocation and optimistic counter CAS shared
by processes on the same SQLite file. finish_authenticate verifies the ES256
ceremony and atomically advances the stored counter; a stale concurrent update
fails. Revoked records remain in inventory and continue to consume quota.
Challenge state remains process-local inside PasskeyAuth, so a multi-instance
deployment using that API needs sticky ceremony routing or a custom shared challenge layer.
The optional passkey-postgres feature (added in v13) adds a separately bound shared manager
with real PostgreSQL/browser evidence; see shared passkey ceremonies.
The adapter does not establish normative WebAuthn conformance, encrypt or
replicate the database, or replace application device-ownership policy.
Application JWTs
The jwt feature provides ApplicationJwtPolicy, versioned HS256 claims, strong
key validation, required issuer/audience/subject/time/JTI claims, bounded TTL and
scope policy, and kid-based key rotation. Lifetimes are whole seconds; a TTL
or max_ttl under one second is rejected. Every verification receives a
JwtRevocationStore. Production policies reject the bundled bounded in-memory
store because it is process-local. With sqlite,
SqliteJwtRevocationStore persists token IDs and monotonic subject session
versions behind a stored quota. Its BEGIN IMMEDIATE mutations are visible to
local processes, expired token rows are pruned before capacity checks, and
ApplicationJwtPolicy::verify_async checks that shared state. This store and
SqlitePasskeyStore accept only an ordinary database file: :memory: in any
form, file: URI filenames and the vfs, immutable and mode=memory URL
parameters fail with InvalidConfiguration.
Both bundled stores cap token-ID rows at three quarters of the quota and at 64
active rows per subject. Past either cap, revoke_token records a subject cutoff
that rejects every token of that subject issued no later than the revoked one,
so one principal cannot exhaust capacity for others and subject revocations keep
a reserve. Subject entries are never pruned. Opening an existing SQLite file adds
two backward-compatible columns; earlier releases ignore subject cutoffs, so
upgrade every process sharing the file.
The SQLite boundary is durable across restarts but not replicated across hosts. The deployment owns trusted paths, file permissions/encryption, backup, availability and disaster recovery. This API does not verify third-party OAuth/OIDC tokens or provide refresh tokens.
RBAC
Implement HasRole for the authenticated user type and install
RequireRoleLayer::<User>::new("Admin"). Authentication middleware must insert that user into
Axum request extensions before the role layer executes.
OAuth2/OIDC
Enable oauth for the rullst_auth::connect re-export. Provider configuration, discovery,
JWKS rotation, and deterministic offline fixtures are implemented by rullst-connect.
Security-sensitive functions return typed errors or a false verification result. The repository’s zero-panic CI checks production library paths; this policy is not an absolute guarantee about all dependencies or host failures.
Rullst Core ⚙️
rullst-core contains Rullst’s runtime primitives, Axum-compatible routing,
state management, health probes, process telemetry, queues, and configuration
helpers.
Core is runtime-only by default. Enable orm for ORM bootstrap/artisan and
database-backed feature flags, and queue-sqlite for the SQLite queue driver.
orm adds no SQLx driver: pair it with drivers-all or one strict-*
backend. The umbrella rullst crate enables none of them by default (up to
13.0.0-alpha.2 its defaults were orm, drivers-all and queue-sqlite), and
domain crates opt in only when they actually use them.
Queue monitoring capabilities are driver-specific. The trait defaults for
listing all jobs, retrying failures and purging failures return
QueueError::Unsupported; they never fabricate an empty snapshot or successful
mutation. purge_failed_jobs is the canonical facade method. The deprecated
purge_completed_jobs name is retained only as a source-compatibility alias for
the historical operation, which actually removed failed jobs. The Redis driver
implements all three: list_all_jobs returns at most 1,000 rows (failed jobs
and dead letters newest first, then processing, pending and scheduled jobs, read
without one atomic snapshot; created_at is empty because Redis does not record
it), retry_failed_job moves a failed job to the tail of the pending list while
keeping its attempt counter, and purge_failed_jobs deletes every failed job
and dead letter.
The v13 list_job_previews(limit, max_field_bytes) (on
QueueDriver and the Queue facade) lists the same records as
QueuedJobPreviews whose payload and error hold at most max_field_bytes
bytes, cut on a UTF-8 boundary, with payload_truncated/error_truncated
flags. SQLite cuts the values in SQL and Redis in one atomic Lua script that
decodes at most limit envelopes, so complete payloads never leave the store.
Custom drivers inherit a default that projects their list_all_jobs (and
therefore still loads complete records).
Cache diagnostics are driver-specific too. Cache::inspect(limit) accepts
1–200 and returns sorted logical-key, UTF-8 value-length and remaining-TTL
metadata for Memory and Redis without the value. Exact keys are still
application data and CacheEntryMetadata::logical_key() belongs only inside an
authorized diagnostic boundary; its Debug output redacts the key. Custom
drivers return CacheError::InspectionUnsupported unless they implement the
bounded method. The live Redis CI/release contract checks metadata, TTL and
non-disclosure; it does not prove cluster/failover or operator authorization.
The Redis cache and queue drivers each keep one lazily opened multiplexed
connection for all operations and replace it after a connection-level failure;
each queue command waits up to 10 seconds for its reply.
The memory cache stores a TTL too large for the monotonic clock (such as
u64::MAX) as non-expiring instead of panicking. A read that finds an expired
entry removes the key only while it still holds that expired value, so a
concurrent refill is kept.
SQLite deletes successful jobs by default. Applications that need a real
Studio/operations history can opt in with
Queue::sqlite_with_completed_history(database_url, retained_jobs). The
validated limit is 1–100,000 records; status transition and pruning commit in
one transaction, and purge_completed_history removes the retained successes.
Rows still contain the original payload, so access control and retention policy
belong to the host. Redis/custom drivers do not inherit this policy implicitly.
The Redis driver bounds its own failure state instead: failed jobs (with
payloads) and dead letters are each retained up to 10,000 entries by default,
the oldest evicted atomically, and
RedisDriver::try_with_failure_retention(failed_jobs, dead_letters) accepts
1–100,000 for each. Failures recorded before that bound existed are not
indexed, so they are not listed and never evicted automatically.
Queue::dispatch and dispatch_at reject an empty job name or one longer
than 256 bytes with QueueError::InvalidConfiguration.
Queue::dispatch_at persists a due timestamp for at most 366 days through the
built-in SQLite and Redis drivers. SQLite filters claims by local wall-clock
milliseconds and claims in due-time order, so a due scheduled or handed-back
job does not wait behind later immediate jobs; Redis atomically promotes
bounded batches using Redis server time. Neither backend claims a scheduled job
early. Execution starts on the first worker poll after it becomes due and
retains the queue’s at-least-once semantics.
Worker drives each pop to completion instead of racing it against
completions or shutdown, because both built-in claims commit before the future
resolves. A job claimed after graceful shutdown was requested is requeued.
When a handler finishes while its timeout or a graceful shutdown is being
processed, the worker records the handler’s own result: only a handler that
was actually cancelled is failed as timed out or requeued, so a success is
never reported as a timeout or run again. Worker::run rejects a zero
max_concurrency, poll_interval or job_timeout and a stalled_after that
does not exceed job_timeout with QueueError::InvalidConfiguration.
Stalled-lease recovery runs when a worker starts and then every
min(stalled_after, 60 s), and it is queue-wide: it returns every processing
lease in the shared SQLite table or Redis namespace that is older than the
recovering worker’s stalled_after, including leases of other workers. Since
v13, workers claim through QueueDriver::pop_with_lease
with their own stalled_after; SQLite and Redis store that lease with the
claim and recovery honours it whatever age the recovering worker uses, so a
pool with a short stalled_after no longer requeues a slower pool’s running
job. Claims without a lease (older workers, custom drivers or direct pop
calls) still stall after the recovering worker’s age: while any exist, every
worker that shares a queue must use a stalled_after longer than the longest
job_timeout of any of them.
A job that crashes, aborts or hangs its worker would otherwise be recovered and
claimed forever, so the SQLite and Redis drivers count stalled leases per job
and fail the job, instead of requeuing it, when its fifth lease stalls. The
failure is listed and retryable like any other failed job, and
retry_failed_job restarts the count. Since v13,
SqliteDriver::try_with_max_stalled_leases and
RedisDriver::try_with_max_stalled_leases change the ceiling (1–1,000, default
DEFAULT_MAX_STALLED_LEASES = 5).
Worker transitions are fenced by the claim’s attempt number. The SQLite and
Redis drivers complete, fail or requeue a job only while it is still processing
under the attempt that pop returned, so a worker whose lease was recovered and
claimed again receives a StateTransition error instead of finishing, failing
or deleting the newer claim. The new QueueDriver::mark_complete_attempt,
mark_failed_attempt and requeue_attempt methods default to the unfenced
methods, so custom drivers keep their behaviour until they override them. The
stale handler may still have run its side effects (delivery stays
at-least-once). SQLite and Redis retry_failed_job keep the attempt counter,
so the fence also holds across a manual retry.
A worker that claims a job whose name it has no handler for hands the claim
back instead of failing it. SQLite and Redis make the job claimable again after
five seconds, behind jobs that are already due, so a worker that registered the
name (for example a newer version during a rolling deploy) can run it; the
delay keeps the claiming worker out of a hot loop, and it still reports
HandlerNotFound each time. From the 720th claim attempt (at least an hour of
hand-backs) the job is failed with HandlerNotFound instead, so a job that no
running worker can handle becomes visible and purgeable; since a retry keeps
the attempt counter, deploy a handler before retrying it.
Custom drivers that do not implement QueueDriver::requeue_attempt_after keep
the previous behaviour and fail the job.
ValidatedForm/ValidatedJson failures keep REST status codes (400, 413,
415 or 422 JSON) for other clients, but an HTMX request receives its
escaped HTML fragment with 200 OK and an X-Rullst-Validation-Status header
carrying that status, because htmx swaps only successful responses by default.
Scheduler::task takes a POSIX five-field expression (minute hour day-of-month month day-of-week) evaluated in UTC. Day-of-week accepts 0-7
(0 and 7 are Sunday, 1 is Monday) and names, so 0 9 * * 1-5 runs Monday to
Friday. When both day fields are restricted, a day matching either one runs
the task (0 0 1 * 1 is the 1st plus every Monday); a field starting with *
keeps the intersection. Earlier releases passed the fields to the cron
crate unchanged, where 1 was Sunday and 0 was rejected. Messaging’s durable
recurring publications keep their documented cron-crate projection.
See time zones for scheduled tasks to
match the fields against a local clock instead of UTC.
WorkerHandle and SchedulerHandle buffer at most 256 undrained errors. Once
the buffer is full, newer errors are dropped, counted by dropped_errors() and
emitted as tracing warnings, so a handle that is kept alive but never drained
does not grow memory. Drain next_error (for example from a supervising task)
to observe every failure. A scheduler attached with Server::schedule is
drained by the server: each task failure is logged as a tracing error on the
rullst::scheduler target when reported, and a past task failure no longer
turns a clean shutdown into Err(ServerError::Scheduler); only a failed
scheduler loop does.
Custom drivers return QueueError::Unsupported for future timestamps unless
they explicitly implement durable scheduling.
ApplicationLifecycle supplies an opt-in process-local startup/readiness/drain
contract. Up to 32 immutable validated component labels can gate readiness and
application admission; /ready publishes only aggregate counts, not labels or
dependency errors. Server::with_lifecycle marks the phase ready after binding,
begins draining before Axum’s graceful wait, and marks it stopped on completion
or startup failure. run_with_shutdown accepts a caller-owned trigger for
embedded supervisors and deterministic tests. The lifecycle does not run
dependency probes, coordinate replicas, authorize users, or guarantee load
balancer propagation.
Since v13, the drain retains admission through the response body, including streaming/trailers, error and cancellation. It does not track upgraded connections or prove client receipt. The two-replica deployment contract exercises this boundary through a real proxy; its hosted workspace and installed-archive checks passed in PR #224, and the final release campaign remains separate.
✨ Core Features & Subsystems
- Axum-compatible routing:
rullst::Routerwraps and converts to/fromaxum::Router; application latency depends on handlers, middleware, build profile, and deployment. - Typed server functions: concrete async
#[server_function]items share owned Serde arguments/results through the versionedrullst.clientv1 envelope. The generated native router and Wasm caller enforce same-origin paths, bounded bodies, correlation and redacted errors; hosts still own identity, tenant, authorization, idempotency and rate-limit policy. - Rullst Radar (
rullst::radar): Collects process RSS/CPU where an OS probe is supported, Tokio task/yield observations when a runtime is available, and process uptime. Unsupported probes returnNone: RSS and CPU have probes on Linux and Windows only, so macOS reports neither. On Linux, RSS comes fromVmRSSin/proc/self/status(correct on 16/64 KiB page kernels), and CPU percent is process CPU time over wall time: the host-wide/proc/statdelta is scaled by its host CPU count, not by the cgroup-limitedavailable_parallelism, so a container saturating a 2-CPU quota reports about 200%. - Prometheus
/metricsExporter: Text-format metrics served atGET /metrics; formatting and collection have bounded runtime cost. - Kubernetes probe routes (
rullst::health): the simplehealth_routerreports process availability and uptime.Serverrecords the health and Radar uptime origin when it starts, unlessinit_health_boot_timeorinit_radarwas called earlier; without aServer, call them yourself. The opt-inhealth_router_with_lifecyclereturns readiness from the same bounded state that gates Server request admission; the application still performs and times out its own dependency checks. Theversionfield of both payloads is the Rullst framework version. To report your application’s version too, usehealth_router_with_version(env!("CARGO_PKG_VERSION"))orhealth_router_with_lifecycle_and_version(lifecycle, env!("CARGO_PKG_VERSION"))(v13), which add it asapp_version; any string works, such as a commit hash read at startup. - Interactive Scalar API Docs (
rullst::scalar): OpenAPI documentation UI mounted at/docs, with a pinned CDN asset and a status-only fallback. A missing or malformedopenapi.jsonreturns503. - Typed framework errors: startup, queues, validation, scheduling, storage,
and other subsystems expose their own typed errors. Applications may compose
those into an application-owned
AppError; Core does not define one global application error type. - Redacted configuration
Debug:DatabaseConfig(and thereforeRullstConfig) prints only the database URL scheme, such aspostgres://<redacted>;db::ReplicationConfigredactsauth_tokenand prints only thesync_urlscheme. Fields stay public and unchanged. - Durable scheduled queues: SQLite and Redis persist bounded due timestamps; the live Redis CI contract proves that an immediate job remains claimable while a future job stays unavailable.
- Opt-in completed-job monitoring: SQLite can retain and atomically prune a configured number of successful jobs; the privacy-safe default remains immediate deletion.
- Bounded token-bucket rate limiter:
RateLimiterkeys IPv4 peers per address and IPv6 peers per /64 by default. It tracks at most 100,000 keys, drops fully refilled buckets and evicts the least recently used ones beyond that cap; a key longer than 128 bytes (for example from a custom extractor returning a token header) is stored as its SHA-256 digest, so the map stays bounded in bytes too. State is process-local, not a distributed limit. When attached toServer, the limiter and the Traffic Shield let exactGET/HEAD /healthand/readyprobes through, so load shedding or an exhausted bucket cannot fail a liveness probe. In a debug Development server withcargo rullst devreloading, its/_rullst/dev-generationpoll,/_rullst/dev-reload.jsand the/_rullst/dev-telemetrypoll ofcargo rullst dashalso bypass both and are not access-logged. In staging and production, successful/healthand/readyprobes are not access-logged either (failing ones are);RULLST_LOG_HEALTH_PROBES=trueprints them again. WithoutRUST_LOG, the default tracing filter isinfo,sqlx=warn; see the telemetry guide. - Development dashboard telemetry (v13): under the same conditions
ServerservesGET /_rullst/dev-telemetryto loopback clients only, over HTTP/1.1 or newer and without proxy forwarding headers: bounded request counters and recent requests (method, path without query string, status, duration), ORM operation counts and slow operations fromrullst.orm.queryspans, and the pending count of a queue passed toServer::with_dev_queue. Bodies, headers, cookies, query strings, SQL and bindings are never recorded. See the telemetry guide. - Production monitoring endpoint (v13, 13.0.0-alpha.3):
Server::with_monitor(rullst_core::monitor::Monitor::new())servesGET /_rullst/monitor/v1onceRULLST_MONITOR_TOKENS(orMonitor::with_token, or an applicationMonitorAuthorizer) provides a credential; without one nothing is mounted. It reports request rate, latency and status classes per route pattern, registered queues, N+1 fingerprints and the radar, never request data or configuration, and requires TLS evidence in staging and production. See the production monitoring guide. - Trusted-proxy client resolution (v13):
Server::trusted_proxiesmountssecurity::TrustedProxyLayeroutside every other framework layer. Only a socket peer inside the listed networks may report the client throughX-Forwarded-Foror RFC 7239Forwarded; the resolved address replacesConnectInfo, so existing rate limiters and lockouts use it unchanged. See Running behind a reverse proxy. - Bounded database flag cache:
DbFeatureDrivercaches a found flag, a flag without a row and a failed or timed-out lookup (missing table, unavailable database) for its TTL, so an undefined flag does not query the database on every evaluation. A failed refresh keeps serving the last value read; one lookup waits at most two seconds and each driver caches at most 4,096 flag names. - Feature flag buckets: percentage rollouts and A/B variants in the Env,
TOML, Memory and DB drivers use
calculate_hash_bucket, a versioned SHA-256 hash over a domain tag, the length-prefixed flag and the identifier. It gives every toolchain, platform and replica the same assignment. Earlier releases usedstd’s unspecifiedDefaultHasher, so upgrading reassigns users to buckets once; percentages and variant weights are kept.TomlFeatureDriver::reloadparses into a new map and swaps it in at once, so concurrent evaluations never see a flag as unset mid-reload. It reads[features]with a TOML parser, so quoted flag names ("checkout.v2"),#inside strings and any valid header spelling work, and dotted keys or[features.<group>]tables become dotted flag names; a file that is not valid TOML falls back to the earlier line reader. When an A/B split’s weights sum to less than 100, an identifier outside them gets the variant"disabled"from the driver that defines the flag; it no longer falls through to a lower-priority driver’s split. Every driver evaluates an A/B flag for an identifier to its variant name, soenabled_foris true only for identifiers assigned a variant named"enabled", andenabled(no identifier) is false for an A/B flag in every driver, including aDbFeatureDriverrow without a rollout percentage; gate code onvariant. The v13FeatureManager::overrides()returns the first-priorityMemoryFeatureDriverofFeatureManager::default()(and of the globalfeature::manager()when it uses the default pipeline), so programmatic overrides reach it. - Project settings (v13):
config::project_setting(name)(alsorullst::config::project_setting) reads an application setting from the process environment first and then./.env, which never overrides the environment and is never loaded into it, byServeror by Artisan commands (db:*,studio); read.envvalues in migrations and seeders with it rather thanstd::env::var. It returnsOk(None)when neither defines the name and fails withConfigError::Read/Parsefor an unreadable or malformed.env; errors never contain.envcontent. Nexusbasic_from_envand generated billing code (BILLING_*) use it. The#[doc(hidden)]server::ProjectSettingsand asyncserver::read_project_settingapply the same precedence for first-party crates such asrullst-mail, andProjectSettings::environmentapplies theServerprecedence forRULLST_ENV/APP_ENV/[app].env; they are not a stable extension point. - Bounded cache metadata: Memory and Redis expose value length and TTL for at most 200 sorted entries, never cached values. Rullst Studio renders keyed opaque identifiers and one-entry invalidation rather than exact keys or bulk flush.
🚀 Usage
Most applications can use the re-exports provided by the umbrella rullst
crate instead of depending on rullst-core directly.
Mounting lifecycle-aware Health Probes & Prometheus Metrics
These optional surfaces are application-owned and must be mounted explicitly.
Protect or isolate /metrics when its operational data should not be public.
use rullst_core::{
ApplicationLifecycle, Router, Server,
health::health_router_with_lifecycle,
radar::radar_metrics_router,
scalar::scalar_docs_router,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let lifecycle = ApplicationLifecycle::new();
let app = Router::new()
.merge_axum(health_router_with_lifecycle(lifecycle.clone()))
.merge_axum(radar_metrics_router()) // GET /metrics (Prometheus)
.merge_axum(scalar_docs_router("/openapi.json")); // GET /docs
Server::new(app)
.with_lifecycle(lifecycle)
.run(3000)
.await?;
Ok(())
}
Running behind a reverse proxy
Behind a load balancer or TLS terminator every connection comes from the proxy, so limits and lockouts would treat all clients as one. List the networks your own proxies connect from, and nothing broader:
use rullst_core::{Router, Server, security::TrustedProxyConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let proxies = TrustedProxyConfig::new(["10.0.0.0/8", "fd00::/8"])?
.trust_forwarded_proto(true);
Server::new(Router::new())
.trusted_proxies(proxies)
.run(3000)
.await?;
Ok(())
}
Or configure the same policy in Rullst.toml; a builder policy replaces it:
[security]
trusted_proxies = ["10.0.0.0/8", "fd00::/8"]
trusted_proxy_header = "x-forwarded-for" # or "forwarded" (RFC 7239)
trust_forwarded_proto = true
Forwarding headers from any other peer are ignored. The chain is read right to
left, skipping trusted hops, and the first untrusted address becomes the
client: ConnectInfo<SocketAddr> is replaced by that IP with port 0, and a
ClientAddr extension records the client, the original socket peer and whether
a proxy supplied it. A missing or malformed header from a trusted proxy keeps the
proxy address and logs one tracing event without header contents. Enable
trust_forwarded_proto only when your proxies overwrite X-Forwarded-Proto
(or set proto=); ClientAddr::forwarded_proto then reports the scheme, and
Nexus accepts an HTTPS report as its TLS evidence. Any host inside a listed
network can choose the client address, so never list client-reachable ranges.
Deferrable jobs and time windows
Opt-in v13 API. Work that need not run immediately (reports,
exports, re-indexing) can be dispatched with a Deferral: a deadline
(run_by) and up to 24 daily TimeWindows, each with an optional fixed UTC
offset (daylight-saving changes are not applied, except for the windows of
Scheduler::deferrable_task_in_tz; an end earlier than the start wraps
midnight). Queue::dispatch_deferred makes the job claimable at the
start of the next allowed window (immediately inside an open window), never
after the deadline; when no window opens before the deadline, it becomes
claimable at the deadline. Without windows, any time before the deadline is
allowed, so the job is due immediately.
Queue::dispatch_deferred_with takes a CarbonAwarePlanner around an
application-provided CarbonIntensitySource (an async trait returning
forecast slots with their unit; the source has a name). The job is placed at
the start of the lowest-value slot that overlaps its windows before the
deadline (ties go to the earliest slot). When the source returns an error or
exceeds the planner’s timeout (2 s by default), the time-window rule is used
and one warning is logged per outage. Core ships no network source; the
deterministic FixedIntensitySource exists for tests and documentation.
The planner only chooses a time from the values the source reports; Rullst
measures nothing and makes no claim about emissions.
Placement is expressed through dispatch_at, so SQLite and Redis store a
deferred job as an ordinary scheduled job: no new column or key, and rows
written by 12.x keep working unchanged. Custom drivers without durable
scheduling reject a future placement with QueueError::Unsupported. The
returned DeferredJob carries the DeferralPlan (run_at, reason of
window, intensity or deadline, the source name, and the source’s value
and unit for an intensity placement), and a rullst.queue.deferral tracing
span records the same fields with the job id. Deadlines are limited to 366
days, like dispatch_at, and execution still starts on the first worker poll
after the job becomes due.
#![allow(unused)]
fn main() {
use rullst_core::queue::{
CarbonAwarePlanner, Deferral, FixedIntensitySource, Queue, QueueError, TimeWindow,
};
use std::time::{Duration, SystemTime};
async fn defer_reports(queue: &Queue) -> Result<(), QueueError> {
// Claimable between 00:00 and 06:00 at UTC-03:00, at the latest in 24 hours.
let night = TimeWindow::daily(0, 0, 6, 0)?.with_utc_offset_minutes(-180)?;
let deferral = Deferral::within(Duration::from_secs(24 * 3_600))?.window(night)?;
let job = queue
.dispatch_deferred("monthly_report", serde_json::json!({"month": "2026-09"}), &deferral)
.await?;
println!("{} due at {:?} ({})", job.id, job.plan.run_at, job.plan.reason.as_str());
// Fixed example values; applications implement CarbonIntensitySource.
let now = SystemTime::now();
let hour = Duration::from_secs(3_600);
let source = FixedIntensitySource::new("example-fixed", "gCO2eq/kWh")
.slot(now, now + hour, 300.0)
.slot(now + hour, now + 2 * hour, 120.0);
let planner = CarbonAwarePlanner::new(source).with_source_timeout(Duration::from_secs(2));
let reindex = Deferral::within(4 * hour)?;
let job = queue
.dispatch_deferred_with(&planner, "reindex", serde_json::json!({}), &reindex)
.await?;
println!("{:?} from {:?}", job.plan.reason, job.plan.source);
Ok(())
}
}
Scheduler::deferrable_task(cron, ScheduleDeferral::within(max_delay)?.window(...)?, handler) applies the same window and deadline rules to each tick of a
recurring task, with the tick as the start and tick + max_delay as the
deadline. Ticks that pass while a deferred run waits are skipped, as for a slow
run. Scheduled tasks do not consult an intensity source; a task can enqueue a
job with dispatch_deferred_with instead.
Time zones for scheduled tasks
Scheduler::task matches its five fields against UTC. With the optional
scheduler-tz feature (v13; rullst and rullst-core both have it),
Scheduler::task_in_tz matches them against the wall clock of a named IANA
time zone, so “07:00 in Brasília” is written as it is said:
use rullst::Scheduler;
let scheduler = Scheduler::new()
// 07:00 São Paulo time, which is 10:00 UTC.
.task_in_tz("0 7 * * *", "America/Sao_Paulo", || async {
// send the morning digest
})?
// Every ten minutes; the zone does not matter for this one.
.task("*/10 * * * *", || async {})?;
An unknown zone name, such as Brasilia, is rejected when the task is
registered with SchedulerError::InvalidTimeZone { zone, message }; a bad
expression still returns SchedulerError::InvalidCron. The day-of-month and
day-of-week fields also follow the local date: 30 22 * * 5 in São Paulo runs
on Friday evening local time, which is Saturday 01:30 UTC.
In zones with daylight saving, one rule applies:
- A wall time that a gap skips (clocks move forward) runs at the first
instant after the gap. In
America/New_York,30 2 * * *runs at 03:00 EDT on the day clocks spring forward, and*/15 * * * *runs once at 03:00 for the skipped 02:00 to 02:45 and then continues at 03:15. - A wall time that a fold repeats (clocks move back) runs once, at its first
occurrence.
30 1 * * *runs at 01:30 EDT and not again at 01:30 EST, and an hourly task runs at 01:00 EDT and next at 02:00 EST.
The zone rules come from the system database (/usr/share/zoneinfo) when it
has the zone, so operating-system updates refresh them; otherwise the copy
bundled with the jiff dependency is used, which keeps minimal container
images without tzdata working. A task’s label in scheduler errors is the
expression followed by the zone, such as 0 7 * * * [America/Sao_Paulo].
Scheduler::deferrable_task_in_tz(expression, zone, deferral, handler)
(added in 13.0.0-alpha.3) is the zoned form of deferrable_task: its ticks follow
task_in_tz, and its TimeWindows are wall times of the same zone, so
TimeWindow::daily(22, 0, 6, 0) stays 22:00–06:00 local time across a
daylight-saving change. Window boundaries follow the same rule as ticks: one
that a gap skips moves to the first instant after the gap, and one that a fold
repeats is its first occurrence; a window that a gap skips entirely does not
open that day. max_delay is elapsed time after the tick. A window of such a
task must not set with_utc_offset_minutes, which registration rejects with
SchedulerError::InvalidConfiguration. deferrable_task keeps UTC ticks
and fixed-offset windows.
use rullst::Scheduler;
use rullst::queue::{ScheduleDeferral, TimeWindow};
use std::time::Duration;
// The 18:00 report may wait up to 12 hours for 22:00–06:00 in São Paulo.
let night = ScheduleDeferral::within(Duration::from_secs(12 * 3_600))?
.window(TimeWindow::daily(22, 0, 6, 0)?)?;
let scheduler = Scheduler::new().deferrable_task_in_tz(
"0 18 * * *",
"America/Sao_Paulo",
night,
|| async { /* build the report */ },
)?;
Axum First-Class Escape Hatches & Tower Interoperability
rullst::Router provides bidirectional conversion with axum::Router and
accepts compatible tower::Layer values:
#![allow(unused)]
fn main() {
use rullst::Router;
use axum::routing::get;
use tower_http::cors::CorsLayer;
async fn handler() -> &'static str { "ok" }
let mut router = Router::new()
.route("/hello", get(handler))
.fallback(|| async { (axum::http::StatusCode::NOT_FOUND, "not found") })
.layer(CorsLayer::permissive());
// Direct conversion to raw axum::Router
let axum_app: axum::Router = router.into();
// Or wrap an existing Axum router
let rullst_app: Router = axum_app.into();
}
🛡️ Security baseline
In staging and production, Server mounts the runtime security baseline from
rullst_core::security: secure headers with a per-request CSP nonce, an exact
CORS allowlist, a bounded WAF, double-submit CSRF and, when
enable_pii_masking is set, response PII masking. rullst-security adds
opt-in layers that overlap some of these. See
which security layer to use, and when before you
combine them.
The secure headers include Strict-Transport-Security: max-age=31536000; includeSubDomains. [security] hsts_max_age_seconds,
hsts_include_subdomains and hsts_preload change it; preload is opt-in
and requires includeSubDomains and a max-age of at least one year, or the
server refuses to start (see
Strict-Transport-Security).
The WAF refuses requests only in staging and production; in development it
logs a warn event with the rule and path of each request it would refuse.
[security] waf_body_exempt_paths lists exact paths whose bodies it does
not inspect, for routes that accept text such as code samples (see
seeing and accepting WAF false positives).
TLS connections use aws-lc-rs as the rustls crypto provider; ring is only
a primitives library (HMAC, digests, randomness, signatures). With the orm,
storage-s3 or telemetry feature, Server start-up installs aws-lc-rs as the
rustls process default before telemetry and the database pool build TLS
clients, and so does the OTLP exporter before it builds its HTTP client. An
application that installs its own provider first keeps it. See
cryptography providers.
🔐 Security Audit & Reliability
Repository workflows exercise Core with unit and integration tests on every change, and with fuzz and Miri campaigns started on demand, within their declared scopes. Consult the exact workflow run and commit for evidence; these tools do not prove the absence of every panic, leak, or vulnerability.
Rullst Capital 💰
“Provider-Neutral SaaS Billing with Stripe and InfinitePay Adapters”
rullst-capital provides a provider-neutral billing base for SaaS and digital
commerce in Rust: billing/metering traits, checkout and subscription contracts,
entitlements, quotas, revenue analytics, paid-invoice rendering, canonical
webhook verification with replay protection, Axum/Actix adapters and offline
mocks. It ships two adapters: Stripe (supported) and InfinitePay
(experimental until validated against a live account). Live readiness must be
established per operation and environment.
Integrate any other gateway in application code by implementing the provider traits: see Writing your own payment provider.
⚡ Capability & Lifecycle Matrix
| Subsystem | Lifecycle Status | Description |
|---|---|---|
| Stripe Adapter | 🟢 [Supported / Bounded] | Owner-bound customer, subscription checkout, bound portal, reconciliation reads, direct charge, meter events, coupons and trials with deterministic mocks. Live account acceptance remains per deployment. |
| InfinitePay Adapter | 🟡 [Experimental] | Deterministic offline checkout/portal/webhook fixtures. Live plan-only checkout and live callbacks fail closed until a reviewed contract is validated against a live account. |
| Custom Providers | 🟢 [Extension Point] | Applications implement BillingProvider (and optionally MeteredBillingProvider) for another gateway and reuse the canonical webhook verifier. See the guide. |
| Outbound Failure Boundary | 🟢 [Implemented / Bounded] | Reviewed live methods share finite timeouts, disabled redirects/ambient proxies, one-MiB JSON parsing, HTTPS checkout-location validation, and redacted permanent/transient/rate-limited failures. Rullst performs no automatic mutation retry. |
| Subscription Lifecycle | 🟠 [Partial] | Checkout, portal, cancellation, pause, usage, coupon, trial, status, and webhook APIs exist; Stripe implements the reviewed live paths, while InfinitePay returns UnsupportedOperation for operations it does not support. |
| Webhook Processing | 🟢 [Implemented / Bounded] | Axum and opt-in Actix middleware call one canonical bounded verifier; adapters implement signature verification, with timestamp freshness checks for Stripe. The opt-in webhook-sql ledger shares bounded payload or semantic-event claims across SQLite, PostgreSQL, MySQL, and MariaDB processes. Relational handlers can claim a stable provider event ID with one domain mutation in a caller transaction. Cross-system exactly-once and reconciliation remain application work. |
| Metered Billing | 🟢 [Implemented / Bounded] | Current Stripe Meter Events shape with provider-specific identity, bounded response binding and deterministic non-live mocks. Custom adapters can return receipts that require application-outbox deduplication. Provider-account evidence remains explicit. |
| v13 Plan Entitlements | 🔵 [Candidate] | Typed per-action tenant/owner, exact feature/plan, mode, status, expiry and reconciliation-age checks. The generated SaaS report performs revision-fenced Stripe refreshes. Local and hosted acceptance remain tracked in the delivery plan; snapshots assert trusted adapter state and are not payment evidence. |
| Paid Invoice Rendering | 🟢 [Implemented / Feature-gated] | Exact validated minor units, escaped HTML, bounded paginated A4 PDF and a final-success e-mail/amount/currency binding. The downstream Mail bridge sends the attachment but durable outbox claiming and exactly-once delivery remain application work. |
| SaaS MRR/ARR Analytics | 🟢 [Implemented / Bounded] | In-memory revenue metrics and churn calculations for supplied records; this is not an accounting ledger or provider reconciliation engine. |
📦 Built-in providers
| Provider | Status | Scope |
|---|---|---|
| 💳 Stripe | Supported | Card checkout, Customer Portal, recurring subscriptions, Payment Intents charges and Meter Events within the reviewed operation boundaries. |
| ⚡ InfinitePay | Experimental | Brazilian Pix/card provider; offline fixtures only until live checkout and callback authentication are validated. |
Neither entry means every provider product, fee, payment method, tax promise or live API path has been homologated by Rullst. The crate README lists the current operation boundary of each adapter.
Removed in v13
v13 removed the Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay,
Coinbase Commerce and PicPay adapters, the Wise payout adapter with the payout
contracts (PayoutProvider, PayoutStatus, PayoutEvent and the payout
registry functions) and the NFS-e fiscal preparation module (fiscal, the
nfse/capital-nfse features and Invoice::to_dps). NFS-e was never
validated with a real municipality; it may return as a separate product outside
the framework. The code remains in git history. Applications that used a
removed provider implement the traits themselves
(guide); see the
v13 migration guide for the row “Capital providers and
NFS-e removed”.
Provider conformance ladder
Validate every provider operation independently. A successful checkout does not validate a portal, refund, usage report, cancellation or webhook contract, and evidence from one provider cannot be transferred to another.
| Level | Required evidence |
|---|---|
| 1. Deterministic offline | Bounds, redaction, failure classification, idempotency material and explicit mock behavior without network access. |
| 2. Protocol fixtures | Exact signed payloads, negative signature/freshness/replay cases and bounded provider response parsing. |
| 3. Official test environment | Provider sandbox/test-mode checkout, webhook, lifecycle and reconciliation exercises. |
| 4. Controlled live acceptance | The smallest provider-permitted real transaction only after account, legal, secret, refund, observability and reconciliation controls are ready; retain redacted evidence. |
The generated SaaS blueprint and make:billing provide a durable Stripe
integration only. It is not a conformance app for InfinitePay or for custom
adapters. Record the exact provider, operation, environment and observed
result; never summarize partial evidence as “all payments work.” Refer to the
official
Stripe testing and
Stripe sandbox
guides when constructing acceptance cases.
🚀 Usage Examples
Shared outbound failure contract
CapitalError::Provider carries a redacted ProviderFailure for request
construction, transport, non-success HTTP status, oversized/malformed JSON, or
semantic response mismatch. Its provider and operation labels are static and
safe for low-cardinality telemetry; the value deliberately omits URLs,
credentials, bodies, and raw transport errors.
#![allow(unused)]
fn main() {
use rullst_capital::{CapitalError, ProviderFailureClass};
fn record_disposition(error: &CapitalError) -> &'static str {
match error {
CapitalError::Provider(failure) => match failure.class() {
ProviderFailureClass::Permanent => "permanent",
ProviderFailureClass::Transient => "transient",
ProviderFailureClass::RateLimited => "rate_limited",
_ => "unknown",
},
_ => "not_provider_transport",
}
}
}
HTTP 429 is rate-limited; transport failures and HTTP 408, 425, and 5xx are
transient; request-build, response-shape, and other HTTP failures are
permanent. Only numeric Retry-After delta seconds are retained and they are
capped at 24 hours. These are scheduling hints, not a generic retry engine:
non-idempotent operations must not be repeated without a durable,
provider-forwarded idempotency key and reconciliation.
1. Initializing a Provider and Creating a Checkout Session
use rullst_capital::providers::stripe::StripeProvider;
use rullst_capital::BillingProvider;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let stripe = StripeProvider::new(
"sk_live_your_stripe_api_key",
"whsec_your_webhook_signing_secret",
);
let session = stripe
.create_checkout_session(
"[email protected]",
"price_pro_monthly",
"https://example.com/billing/complete",
)
.await?;
println!("Checkout URL: {session}");
Ok(())
}
This legacy email-based trait method remains for source compatibility. New
Stripe integrations use the customer-bound create_customer and
create_subscription_checkout flow; see the
owner-bound Stripe checkout.
2. Provider-Specific Metered Usage
MeteredBillingProvider uses an associated request type so each adapter keeps
its own provider identity instead of guessing from one subscription ID.
StripeMeterEvent implements the current form-encoded Meter Events contract
and forwards its identifier as both event identity and idempotency header.
The Stripe path validates positive bounded quantities, binds accepted
responses, caps response JSON to one MiB and returns visibly non-live
deterministic mocks. A Stripe identifier has only rolling provider
deduplication. A custom adapter whose provider exposes no deduplication key
returns UsageDeduplication::ApplicationOutboxRequired, so the application
claims the event key in a durable outbox before sending. Live-account
acceptance, retry/reconciliation and entitlements remain application/release
evidence.
3. Payment-Bound Invoice PDF and Mail
Enable rullst/capital-mail or the separate rullst-capital/invoice-pdf and
rullst-mail/capital-invoice features. A PaidInvoice can be constructed only
from final Succeeded evidence matching the invoice recipient, exact
minor-unit total and currency. PaidInvoiceDelivery::prepare generates escaped
HTML and a bounded PDF attachment and runs Mail’s mandatory pre-flight.
The stable delivery key is an application outbox identity, not a distributed lock. The application must reconcile webhooks, claim that key atomically and own at-least-once retries/provider attachment policy.
4. Verified Webhook Signature Handling
The low-level provider contract below illustrates exact-byte verification. HTTP
applications should normally mount verify_webhook on Axum or
verify_webhook_actix_with_state on Actix so body limits, normalized event
insertion, and replay rejection are applied before the handler. The default
store is process-local; the opt-in webhook-sql feature accepts an
Arc<SqlWebhookReplayStore> in WebhookMiddlewareState for cross-process
admission. Active claims are never evicted to admit new work. Webhooks
use constant-time cryptographic verification where applicable:
#![allow(unused)]
fn main() {
use axum::{body::Bytes, http::HeaderMap, response::IntoResponse};
use rullst_capital::providers::stripe::StripeProvider;
use rullst_capital::BillingProvider;
use std::collections::HashMap;
pub async fn handle_stripe_webhook(
headers: HeaderMap,
body: Bytes,
) -> Result<impl IntoResponse, axum::http::StatusCode> {
let stripe = StripeProvider::new(
"sk_live_api_key",
"whsec_your_webhook_signing_secret",
);
let signature = headers
.get("Stripe-Signature")
.and_then(|v| v.to_str().ok())
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
let provider_headers = HashMap::from([(
"stripe-signature".to_string(),
signature.to_string(),
)]);
// Verifies the provider signature and timestamp before parsing the event.
let event = stripe
.handle_webhook(&body, &provider_headers)
.map_err(|_| axum::http::StatusCode::UNAUTHORIZED)?;
println!(
"Verified subscription {} with status {:?}",
event.subscription_id,
event.status,
);
Ok(axum::http::StatusCode::OK)
}
}
SQL-backed middleware claims the payload before dispatch. Treat it as a
fail-closed replay firewall, not an exactly-once delivery guarantee. For an
atomic relational state change, verify the exact payload through the selected
provider, obtain its stable event identifier, then call
check_and_record_event_key_with_transaction inside the same transaction as
the domain mutation. Provider API calls, e-mail, queues, and other systems still
need an outbox, idempotent consumers, and reconciliation.
🔒 Security Invariants
- Constant-Time Verification: HMAC webhook signatures and explicit
mock_*fixture secrets are compared withsubtle::ConstantTimeEqor ring’s constant-timehmac::verify. - Fail-Closed Live Modes: Operations without a reviewed live contract, including InfinitePay live checkout and callbacks, return
UnsupportedOperationbefore network I/O instead of a fabricated success. - Bounded Egress: Reviewed live provider methods use a pooled client with finite connect/request timeouts, disabled redirects and ambient proxy discovery, bounded JSON, and redacted typed failure evidence. Returned checkout URLs must be absolute credential-free HTTPS without fragments.
Rullst Studio 📊
Important
This page describes the v13 prerelease, published as
13.0.0-alpha.4. Cargo never selects a prerelease on its own, so its dependency example pins that version exactly. The v12 line (rullst-studio@12) reached end of life on 16 October 2026; see the release record. Use a path dependency only for checkout-local review.
rullst-studio is the built-in, local-first administration and monitoring
dashboard for Rullst. It exposes bounded database, queue, cache and telemetry
views from the sources explicitly supplied by the application.
✨ Features
- Database inspector: Read and filter configured SQLx tables, edit bounded
primitive non-key values, delete one complete-primary-key-selected row with
exact confirmation, and inspect a live ER diagram. Tables whose key includes
a column outside the ASCII identifier boundary stay read-only, and each write
commits only when exactly one row changed. Views show 25 rows, cut cell text
to 256 characters in the database and accept search terms up to 256 bytes. SQLite, PostgreSQL, MySQL and
MariaDB run executable mutation contracts; PostgreSQL runs under both the
default
sqlx::Anybuild andstrict-postgres. - API reference: Render the operations of an
OpenApidocument explicitly supplied by the application at/studio/api(server-side, no script or remote asset) and serve the document at/studio/api/openapi.json; Studio does not infer arbitrary Axum routes or send requests for you. - Worker queue monitoring: Inspect up to 50 records exposed by a supplied
Rullst queue and request retries. Records come from the bounded v13
list_job_previewsprojection: at most 2 KiB of each payload and error leaves the SQLite or Redis store, and previews cut there are marked…. SQLite removes successful jobs, so the view is not durable completion history. - Safe configuration view: Environment values are deny-by-default redacted; typed runtime configuration is projected without URLs, paths, or secrets.
- Feature flags manager: Toggle database-backed flags and immediately
invalidate already-warm
DbFeatureDrivercaches in the same process. Studio never creates therullst_feature_flagstable; a missing table is reported with the schema to add in a migration. - Distributed diagnostics: Visualize in-process sources plus bounded, attribute-free v1 spans from a separately mounted HMAC-authenticated push endpoint. Slow-query and repeated-label findings are heuristics; no SQL text, bindings, attributes, headers, bodies or error details are accepted.
- Cache inspector: An explicitly supplied memory or Redis
Cacheexposes bounded metadata and individual invalidation through opaque process-bound tokens. Values, exact keys and bulk flush remain unavailable in the UI. - Local-first security: The supported launcher binds to loopback, verifies
the direct peer and local
Hostauthority on every request, and requires a same-originOriginheader for mutations. - Self-contained interface: Compiled CSS and the bounded request-stream client are served from the Studio router itself. The core operator pages do not require Google Fonts, HTMX, Mermaid, a browser-side CSS compiler, or a third-party CDN.
🚀 Quickstart
Install the v13 prerelease with an exact pin:
cargo add rullst-studio@=13.0.0-alpha.4.
Launching the Studio
The supported mode is a standalone debug server. run_studio and
Studio::into_router(LocalStudioAccess::loopback_only()) reject release builds
and requests whose direct peer is not verified as loopback. Servers composing
the router manually must preserve Axum ConnectInfo<SocketAddr>. The access
capability also rejects DNS-rebinding-style non-local Host values,
cross-origin requests, and unsafe requests without an Origin header.
Responses use Referrer-Policy: same-origin, so browsers send the real origin
on Studio’s own form posts. Origin: null passes only together with
Sec-Fetch-Site: same-origin, as browsers send it when a host layer imposes
no-referrer; a bare null origin or a same-site document on another local
port is rejected.
Database, queue, cache and feature-flag writes also require a crate-private
marker that only this capability installs, so raw subrouters mounted elsewhere
return 403 for them.
Remote projects (added in 13.0.0-alpha.3)
The rullst-studio binary (cargo install rullst-studio --version =13.0.0-alpha.4 --features remote --locked, then
rullst-studio [--port PORT], or cargo rullst studio --remote; library
users enable the non-default remote feature) serves
Studio on 127.0.0.1:5556 without an application process and reads the
read-only /_rullst/monitor/v1 document of the projects you add: overview,
routes, queues and N+1 pages with a project switcher. It keeps the loopback
Host, same-origin and marker checks above, adds a per-process form token,
and runs in release builds because it serves no local data: the data
browser, .env viewer, AI and API playgrounds and migration pages are not
mounted. Projects are stored owner-only in the user’s configuration
directory, with each token AES-256-GCM encrypted under a key in the data
directory and bound to its name and base URL. Requests require https://
(or http:// to loopback), follow no redirects, ignore proxy variables and
read at most 1 MiB in 5 seconds. See the
production monitoring guide.
History and alerts: projects marked as watched are polled every 60 s while
the binary runs; their aggregates go to a bounded owner-only SQLite file
(seven days) charted on /studio/history, and per-project rules (unreachable,
5xx rate, p95, pending jobs, queue failures) fire with a cool-down on
/studio/alerts, optionally posted once to an encrypted https:// webhook.
Studio uses the ORM pool that the application initialized. When none exists,
its first database view initializes one from the resolver shared by Server
and Artisan (process DATABASE_URL, then ./.env, then [database].url in
Rullst.toml). Without a configured database it reports the database tools as
unavailable and creates nothing; there is no SQLite fallback.
The earlier StudioLayer embedded-production idea was never implemented.
Keeping an authenticated shared Studio is worthwhile, but it needs its own
explicit identity/RBAC/TLS policy before it can become a supported mode.
Since 12.1.0, the raw data_browser::router() also serves the assets and
Cache routes its layout uses (12.0.0 omits them; the full local builder is
not affected). When upgrading from 12.0 with application-level workaround
routes, see the
12.1.0 composition fix and migration checklist.
CLI Launch:
Inside a project, the CLI runs the application with the studio argument
(cargo run -- studio):
cargo rullst studio
Core’s Artisan handler then serves a smaller local compatibility UI (database,
AI, telemetry, security, capital and span pages) on 127.0.0.1:5555, not the
rullst-studio router described on this page.
Authenticated trace producers
#![allow(unused)]
fn main() {
use rullst_studio::distributed_traces::{
DistributedTraceStore, TraceIngestionKey, TraceIngestor,
};
use rullst_studio::{LocalStudioAccess, Studio};
fn build() -> Result<(), Box<dyn std::error::Error>> {
let store = DistributedTraceStore::new(2_048)?;
let key = TraceIngestionKey::new(std::env::var("RULLST_TRACE_INGESTION_KEY")?)?;
let ingestion = TraceIngestor::new(store.clone(), "api-1", key)?;
let _push_only_application_router = ingestion.router();
let _local_viewer = Studio::new()
.with_distributed_traces(store)
.into_router(LocalStudioAccess::loopback_only())?;
Ok(())
}
}
Each ingestor binds one exact producer name to one key; mount separate producer
endpoints over the same store when needed. TraceBatchSigner::new binds the
same pair and produces the byte-identical JSON body plus source,
timestamp, nonce and signature headers. Mount the push-only router under an
application path; it exposes no Studio reads or administrative mutations. The
application/deployment still owns TLS, network policy, key distribution and
rotation, clock synchronization, availability and label redaction. The store
is bounded process memory, not OTLP or durable trace storage.
🔐 Security Audit
rullst-studio currently supports verified-loopback development access. It does
not provide a built-in shared-intranet or production authentication mode. Do not
expose raw subrouters publicly; a future shared mode must fail closed behind
application-owned authentication, administrator authorization, TLS, and network
policy.
The built-in migration page intentionally links to cargo rullst db:* commands.
The compatibility HTTP mutation handlers return 501 Not Implemented because
the standalone Studio has no configured migration or seeder registry. Queue and
revenue panels likewise show only data supplied by the selected driver or
application; unsupported operations return errors instead of simulated success.
The SSE request view records method, URI, status and latency only. It does not
capture bodies or headers by default because those can contain authentication,
session, payment, and personal data. A successful Studio toggle invalidates all
already-warm DbFeatureDriver entries in the same process. Other processes and
direct database writers remain visible through the configured TTL unless the
application supplies distributed invalidation.
Studio::with_cache opts one Cache into metadata-only inspection. The page
shows a keyed opaque identifier, UTF-8 value byte length and remaining TTL for
at most 100 entries. It never returns the value or logical key, offers no bulk
flush, and requires the verified local mutation marker to invalidate one
entry. Memory and Redis implement this contract; custom drivers return an
explicit unsupported state unless they implement bounded inspection.
📚 Documentation
For supported usage and security boundaries, see this book and the capability ledger.
Rullst Nexus
rullst-nexus is a server-rendered administrative panel for models that
implement NexusModel, either manually or through #[derive(Nexus)]. The
derive infers primitive widgets and accepts explicit semantic metadata such as
kind = "textarea" and kind = "enum", options = "draft, published". Nexus
provides registered-model CRUD, search, pagination, semantically validated form
widgets, bounded selected-record delete/deactivate actions, telemetry, a
security view and an optional AI query page (the ai feature of
rullst-nexus, nexus-ai on the rullst facade; see
AI assistant). The current interface uses
server-side HTML and HTMX; Wasm islands,
drag-and-drop media management and automatic relationship discovery described by
older documentation were not implemented. They remain worthwhile separate
features, but must not be presented as current behavior.
Build a protected panel
Nexus fails closed: try_build() requires an explicit validated access policy.
The generated-app helper permits credential-free access only in debug builds and
only for a loopback peer proven by Axum ConnectInfo. Release builds require
NEXUS_ADMIN_USERNAME and a unique NEXUS_ADMIN_PASSWORD of at least 16
characters, each read from the process environment or else the working
directory’s .env (never loaded into the process environment). A malformed or
unreadable .env fails with NexusBuildError::InvalidDotenv.
#![allow(unused)]
fn main() {
use rullst_nexus::{Nexus, NexusAuthPolicy};
fn build() -> Result<axum::Router, Box<dyn std::error::Error>> {
let access = NexusAuthPolicy::local_development_or_basic_from_env()?;
let nexus = Nexus::new()
.with_auth_policy(access)
.with_brand("Application Admin")
// .register::<User>()
.try_build()?;
let app = axum::Router::new().nest("/nexus", nexus);
Ok(app)
}
}
That is the plain-Axum form. On a rullst::Router, as in the generated
starters, mount it with .nest_axum("/nexus", nexus): rullst::Router::nest
accepts only another rullst::Router, and there is no with_state (pass
shared state with .layer(Extension(state))). A release build without the
credentials makes local_development_or_basic_from_env() return
NexusBuildError::MissingCredential; the
Nexus guide
shows how to start the application without Nexus in that case, as the
Portfolio and ERP examples do.
An application with its own login can instead select
NexusAuthPolicy::application(authorizer): its NexusAuthorizer sees each
request and answers Allow (with the audit actor ID), SignIn (redirect to
the login page, or 401 with HX-Redirect for fragments and mutations),
Deny (403) or an error (503). The SaaS starter and the LMS example use it, through
local_development_or_application, for signed-in users whose users.is_admin
is true; see
Using the application’s sign-in.
The serving boundary must preserve the socket address, for example with Axum’s
into_make_service_with_connect_info::<SocketAddr>(). Basic Auth additionally
requires the application-owned NexusVerifiedTls capability inserted only after
validating a trusted TLS terminator, or an HTTPS report accepted by Core’s
TrustedProxyLayer from a listed proxy network with trust_forwarded_proto
enabled. The same layer (Server::trusted_proxies) gives each client behind the
proxy its own lockout bucket. Never derive either from an untrusted forwarded
header.
NexusAuthPolicy::protect_router can apply the same administrator boundary to
application-owned operational routes, as the ERP example does for its whole
back office: the dashboard and the inventory mutations.
Capability boundary
- Implemented: explicit model registration and a compile-tested derive;
server-rendered tables/forms;
bounded/validated registry metadata; parameterized and allowlisted SQL
identifiers; bound record values; server-side pair/byte limits and semantic
validation for Boolean, enum, JSON, number, date, e-mail and HTTP(S) URL
fields; CRUD, search, pagination, sort and batch operations, also for
composite primary keys; typed date/time writes that native date/time columns
accept; CSRF middleware; fail-closed loopback/Basic access and per-request
access through the application’s own sign-in; bounded Basic Auth failure throttling;
opt-in exact text-column tenant scope across every built-in read and mutation,
derived only from a trusted Core
TenantContext; and opt-in transaction-coupled minimized mutation audit with a fixed cross-database schema and bounded export API. - Batch boundary: at most 1,000 explicitly selected IDs; deactivation is
available only for a writable Boolean
is_activeoractivefield. - Application responsibility: identity, tenant membership and domain policy;
model/field authorization for global models and custom routes; database
privileges; trusted proxy and TLS configuration; secret rotation; immutable
external audit delivery, retention and backup; schema/type compatibility
(on PostgreSQL Nexus casts number, relation, Boolean, date and time writes,
but editable JSON and enums need text columns); and ownership rules beyond
the explicit tenant column. The built-in audit
table is transaction-coupled in the same database, not append-only or
tamper-evident, and records committed mutations rather than rejected
attempts. Multiline intent and
variants from an unrelated Rust enum require explicit
#[nexus]metadata; the struct derive does not invent them. - Not implemented: a generic
NexusLayer, automatic ORM schema reflection, automaticHasMany/BelongsTowidgets, full rich-media management and a shared-production authentication service. These ideas may be implemented when they have typed contracts and proportional tests.
For a complete model example and the local/release access flow, see Rullst Nexus: Explicit Admin CMS.
Rullst AI
Vision preserved: capability-typed schema support, local-model boundaries, and autonomous-agent ideas remain itemized with an implementation opinion in the capability ledger.
rullst-ai is a provider-agnostic LLM client with mandatory outbound prompt-injection checks,
PII masking, deterministic offline fixtures, JSON mode, explicit JSON Schema output, and a bounded
tenant-aware RAG pipeline.
Provider capabilities
| Provider | Chat | Vision | Embeddings | JSON mode | Native JSON Schema |
|---|---|---|---|---|---|
| OpenAI | yes | yes | yes | yes | yes |
| Gemini | yes | yes | yes | yes | yes |
| Anthropic | yes | yes | no | prompt-constrained | no |
| DeepSeek | yes | no | no | yes | deepseek-v4-flash |
| Ollama | yes | model-dependent | yes | yes | local API only |
| OpenAI-compatible local/cloud | yes | declared | declared | declared | declared |
Unsupported capabilities return AiError::UnsupportedCapability; the client does not silently
switch to an unrelated endpoint or represent a fixture as a live-provider result.
Anthropic requests allow up to 16,000 output tokens unless AnthropicProvider::with_max_tokens
sets another limit (the Messages API requires one, and adaptive thinking counts toward it; a longer
reply may also need a longer with_request_timeout). A reply the API reports as cut short (stop_reason
max_tokens or model_context_window_exceeded) or declined (refusal) returns
AiError::ApiError instead of the partial text. OpenAiProvider sends no output limit on text or
vision requests, so the model’s own limit applies, and a reply whose finish_reason is length or
content_filter likewise returns AiError::ApiError.
OpenAiCompatibleProvider covers servers implementing the named OpenAI
/chat/completions and optional /embeddings shapes. It defaults to chat-only;
vision, embeddings, JSON mode, and JSON Schema must be declared for the exact
endpoint/model pair. try_local permits unauthenticated HTTP only on a literal
loopback IP, try_local_with_bearer adds explicit local authentication, and
try_cloud requires HTTPS plus a Bearer credential. All three
disable redirects and environment proxies and bound response bodies. Different
protocols use a custom public AiProvider, not an arbitrary-HTTP mode.
Guarded client
#![allow(unused)]
fn main() {
use rullst_ai::{AiClient, AiError, providers::openai::OpenAiProvider};
async fn answer(api_key: String, user_text: &str) -> Result<String, AiError> {
let client = AiClient::new(OpenAiProvider::new(api_key));
client
.chat()
.system("Answer concisely.")
.user(user_text)
.send()
.await
}
}
AiClient::prompt, chat, vision, embedding, JSON, and structured-output calls run the same
guardrail stage before provider dispatch. Built-in providers repeat the check on direct trait calls.
Call custom AiProvider implementations through AiClient when the application needs the same
mandatory boundary.
The current guardrail blocks deterministic injection patterns, provider delimiter tokens, external
Markdown image beacons (inline or reference images with a remote or unclassifiable destination,
read with CommonMark escapes and character references and outside code spans and code blocks;
an unmatched ASCII reference such as vec![x] is literal text; raw HTML <img> is not
inspected), and
invisible Unicode controls, including zero-width, bidirectional
embedding/isolate, tag and other default-ignorable characters with no ordinary use in text. Soft
hyphens, bidirectional marks and emoji variation selectors are removed before phrase matching
instead of being blocked. Check-digit-valid CPF/CNPJ numbers (canonical formatted or unformatted),
card-like digit runs and email usernames are masked before outbound transmission; alphanumeric CNPJs
and other identifiers are not recognized. This is a bounded heuristic control, not proof that
arbitrary input or model output is safe; authorization, tool permissions, output encoding, and
domain validation remain application responsibilities.
Adaptive evaluation runner
AdaptiveAiEvaluator<P> runs application-defined multi-turn strategies over
static provider dispatch and the mandatory prompt guardrail. One scenario is
limited to 32 turns, 16 KiB per generated prompt and 2 MiB per response, with
an independent deadline and explicit cancellation. Each observation exposes a
bounded response only while the synchronous strategy chooses pass, fail,
inconclusive or its next prompt.
The versioned JSON report keeps the exact caller-supplied suite/subject labels,
provider name, status, terminal code and per-turn byte counts/outcomes. It does
not retain prompts, responses or provider error bodies and can itself be sent
through AuditDeliveryClient. The subject label is not automatic model
discovery, and the deterministic offline runner test is not live-model
evidence. Operators must version their scenario code/corpus and execute it
against each exact model/configuration they intend to approve; no passing suite
proves universal safety, groundedness or jailbreak resistance.
Authenticated audit delivery
AuditDeliveryClient can export an application-minimized RAG, tool or provider
event to one exact endpoint. Cloud configuration requires HTTPS; local
development allows HTTP(S) only on a literal loopback IP. Each JSON envelope is
limited to 16 KiB and HMAC-SHA256 authenticates the exact bytes together with
the key ID and Unix-millisecond timestamp. A caller-generated event ID remains
stable across at most five attempts, and success requires a closed JSON
acknowledgement that repeats that ID. Cancellation covers request, response and
retry waits; empty or mock_* keys select a deterministic offline fixture.
Only transport/deadline failures, HTTP 429 and HTTP 5xx are retryable. Because a timeout can happen after remote acceptance, the receiver must enforce idempotency by event ID as well as signature/freshness validation. The client does not minimize arbitrary serialized data, retain a durable outbox, rotate keys, authorize operators or provide a SIEM receiver. Those remain explicit application/deployment responsibilities.
Token usage (v13)
AiProvider::chat_with_usage, ChatBuilder::send_with_usage and
StreamSummary::usage() expose the token counts a provider reports (input,
output, total and cached input) from its documented response fields. Absent
counts stay None; nothing is estimated or priced.
Bounded streaming and cancellation
StreamingAiClient<P> is a static-dispatch extension for genuinely
incremental providers. The OpenAI-compatible adapter implements the strict SSE
path only when the exact endpoint/model configuration opts into
with_streaming(). It checks the prompt before I/O, requires
text/event-stream and [DONE], bounds the raw response, chunk count, each
chunk and aggregate output, and rejects malformed or truncated events and a
finish_reason of length or content_filter with AiError::ApiError.
Anthropic and Gemini stream through their native SSE protocols (v13) with the same bounds and cancellation; a truncated or withheld reply fails in both the streaming and non-streaming paths.
AiCancellation is cloneable and aborts a supported request while it is
waiting for headers or another body chunk. That drops the local request future;
it cannot prove that an upstream server stopped work or billing. The other
built-in transports and ordinary non-streaming calls retain deadline/drop
semantics until their different wire protocols have equivalent tests.
Tenant-aware chat memory
StatefulChat<M> is a static-dispatch orchestration boundary over
ChatMemory. It binds every conversation to trusted TenantContext, loads a
bounded even history, calls the guarded client, and atomically appends the user
and assistant halves after successful generation. A response that the guardrail
would block when the history is replayed is rejected as
StatefulChatError::Generation(AiError::BlockedByFirewall(_)) and is not
stored. InMemoryChatMemory is a bounded deterministic offline store.
With the opt-in umbrella ai-sql-memory feature, SqlChatMemory supplies a
dedicated SQLx Any pool and fixed schema for SQLite, PostgreSQL, MySQL, and
MariaDB. Its revision compare-and-swap rejects stale cross-process writers. It
does not retry the provider call, because doing so could duplicate cost or side
effects. Tenant and conversation IDs compare case-sensitively on every backend:
new MySQL/MariaDB tables use ascii_bin key columns, and every tenant-scoped
MySQL/MariaDB statement also compares the key byte-exactly, so an older
case-insensitive table fails closed until the migration in the
crate README
is applied. The AI integration tutorial
shows the complete setup and application-owned security/retention boundary.
Tenant-aware RAG pipeline
RagPipeline::answer performs guarded embedding, calls a static-dispatch RagRetriever, applies
per-document and total Unicode-safe budgets, guards and masks every selected passage, generates a
grounded response, returns source metadata, and records one terminal audit event. It requires a
trusted TenantContext, rejects differently tagged documents, and fails with RagError::NoContext
instead of asking the model to answer without retrieved evidence.
The bundled InMemoryRagRetriever is a bounded tenant-partitioned cosine index for offline tests,
development, and small ephemeral datasets. It is neither durable nor distributed. A production
application can implement RagRetriever over ORM pgvector or Qdrant, but that adapter must bind the
trusted tenant and ownership predicates in the authoritative datastore. The pipeline’s tag check is
defense in depth, not a replacement for datastore authorization.
The mandatory audit event stores the tenant, a SHA-256 correlation digest, counts, character budget,
and outcome. It deliberately omits raw questions, documents, embeddings, provider bodies, and model
answers. The digest is not encryption and can be guessed for low-entropy questions.
DurableRagAuditTrail and DurableToolAuditTrail provide bounded synchronous local files with
distinct version headers, SHA-256 frame integrity and restart/quota/corruption validation. They are
single-process writers and do not supply authenticity, rotation, retention, backup or external
delivery; multi-instance deployments can implement the same audit traits over their destination.
Follow the tenant-bound RAG tutorial for the complete offline flow and production integration boundary.
Versioned offline evals
The packaged evals/guardrails-v1.json corpus freezes deterministic injection,
jailbreak, and PII regressions. The repository gate validates unique IDs and
required categories, then runs every case across all six built-in transports in
offline mode. It is deliberately not presented as a safety benchmark:
adaptive attacks, tool selection, hallucination, and live provider/model
versions require separate eval suites.
Strict egress policy
EgressPolicy::strict() starts with no permitted destination. After an exact
host allowlist is configured, EgressFetcher permits HTTPS and explicit ports,
blocks credentials/local/private/metadata/reserved addresses, validates every
DNS answer, pins those answers in a proxy-free reqwest client, verifies the
connected peer, follows redirects only after repeating policy, and bounds time
and streamed bytes. The fetcher is opt-in: it cannot protect arbitrary
application/provider HTTP clients, and tenant authorization, content schema and
data minimization remain caller contracts.
Offline mode
OpenAI, Gemini, Anthropic, DeepSeek, and compatible cloud endpoints use
deterministic offline mode when their API key is empty or begins with mock_.
Ollama uses an empty or mock_* host; the compatible adapter also exposes an
explicit mock constructor. Plain try_local is deliberately live because no
credential is its valid loopback configuration. Offline branches return before
HTTP dispatch and cover each capability the provider declares. Unsupported
capabilities remain typed errors in offline mode.
AiClient::auto() checks OPENAI_API_KEY (with OPENAI_BASE_URL and OPENAI_MODEL for a
compatible endpoint), ANTHROPIC_API_KEY, GEMINI_API_KEY, DEEPSEEK_API_KEY, GROQ_API_KEY
(with GROQ_MODEL) and OLLAMA_HOST (with OLLAMA_MODEL, default llama3). If none is configured, it selects an offline OpenAI fixture;
it does not probe localhost implicitly. OLLAMA_HOST (and the host given to OllamaProvider::new)
is read the way Ollama reads it: a scheme-less 127.0.0.1:11434 or localhost means http and
port 11434 unless a port is named.
JSON mode and structured output
JSON mode requests a parseable JSON value and deserializes it in Rust:
#![allow(unused)]
fn main() {
use rullst_ai::{AiClient, AiError, providers::openai::OpenAiProvider};
async fn example() -> Result<(), AiError> {
let client = AiClient::new(OpenAiProvider::new("mock_local"));
let value: serde_json::Value = client.json_prompt("Summarize this record").await?;
let _ = value;
Ok(())
}
}
Native structured output requires an explicit schema and fails when the provider cannot enforce it:
#![allow(unused)]
fn main() {
use rullst_ai::{AiClient, AiError, StructuredOutputSchema, providers::openai::OpenAiProvider};
async fn example() -> Result<(), AiError> {
let client = AiClient::new(OpenAiProvider::new("mock_local"));
let schema = StructuredOutputSchema::new("answer", serde_json::json!({
"type": "object",
"properties": {"ok": {"type": "boolean"}},
"required": ["ok"],
"additionalProperties": false
}))?;
let value: serde_json::Value = client
.structured_prompt_with_schema("Evaluate the input", &schema)
.await?;
let _ = value;
Ok(())
}
}
Provider-side schema enforcement and Rust deserialization do not replace application-specific
semantic validation. In offline mock mode the deterministic fixture follows at most 16 nesting
levels, 32 items per array and 4,096 generated values; larger schemas fail with
AiError::InvalidSchema.
Current boundaries
Streaming for the OpenAI, DeepSeek and Ollama transports (outside the
compatible adapter), provider-native tool execution loops, first-party external
vector-store RagRetriever adapters, maintained domain-specific evaluation
corpora, and compile-time schema derivation remain roadmap work. The
SQL memory does not supply raw-text encryption, ownership within a tenant,
retention or provider auditing; the in-memory vector utilities and tool registry
do not create an authorization boundary by themselves. Authenticated audit
delivery does not replace a durable outbox or certify a receiver’s retention,
availability or security operations.
Rullst Security 🛡️
“Defense-in-Depth RASP, Cryptographic Vault & Runtime Protection for Rust”
rullst-security provides authenticated field encryption, bounded defensive
middleware, local abuse controls, and security telemetry. Its RASP/DLP rules are
defense-in-depth heuristics: they reduce specific risks but do not establish
complete OWASP coverage, replace parameterized SQL/authorization, or certify the
application that mounts them. Which security layer to use, and when
compares these layers with the Core baseline and gives one recommended
production stack.
⚡ Capability & Lifecycle Matrix
| Subsystem | Lifecycle Status | Description |
|---|---|---|
| Rullst Vault | 🟢 [Implemented] | Authenticated AES-256-GCM encryption with 96-bit nonces, AAD, versioned envelopes, and keyring rotation. Key custody remains external. |
| Bounded RASP | 🟢 [Implemented: defense in depth] | ASCII signature matching plus one decoding pass for URI, headers, and bounded textual/JSON/XML/form bodies. Body media types are classified case-insensitively, with +json/+xml suffixes and prefixed form types, so every body axum’s Json or Form extractor accepts is inspected. A stock PowerShell User-Agent product token (PowerShell/7.4.1, WindowsPowerShell/5.1…) is not treated as the powershell command keyword; execution syntax still blocks. Decoding and body inspection may allocate. |
| Login Guard Tarpit | 🟢 [Implemented: local] | Progressive delay decisions and bounded, expiring in-memory jails keyed by a hashed identity. The caller performs the returned delay. |
| Fixed-Window Rate Limiter | 🟢 [Implemented: local] | In-memory limiter keyed from the verified socket peer: per IPv4 address and per IPv6 /64, with IPv4-mapped IPv6 keyed as IPv4. It tracks at most 16,384 keys and does not coordinate multiple processes. |
| Redis Rate Limiter | 🟢 [Implemented: feature-gated foundation] | redis-rate-limit uses an atomic fixed-window Lua script, namespace validation, hashed client keys and TTL-derived retry metadata. A limiter and its clones share one lazily opened multiplexed connection and reconnect after a connection-level failure instead of connecting per check. Empty/mock_* URLs select an explicit process-local test mode that tracks at most 16,384 client keys and denies new keys while all tracked windows are active; call require_distributed() at production startup. Windows must be at least 1 ms. A live contract proves independent clients share one Redis budget; cluster/failover remains application evidence. |
| DLP & Secret Masking | 🟢 [Implemented: bounded] | Masks complete PEM private-key blocks (PKCS#8 plain or encrypted, RSA, EC, DSA, OpenSSH and OpenPGP), AWS access-key patterns, and credentials in supported textual database URLs. Textual responses are text/*, JSON, XML (including +xml), YAML and application/javascript. A 206 range that would need masking is withheld with 502 instead of losing its Content-Range; a multipart/byteranges response is checked part by part and withheld the same way when a textual part would need masking or the body cannot be split exactly (at most 256 parts, 8 KiB of headers per part). Each range is inspected on its own. Binary, compressed, streaming, unknown-size, and oversized bodies are not rewritten. |
| Local SIEM Journals | 🟢 [Implemented: bounded local] | DurableSiemSpool preserves synchronized unsigned SHA-256 frames. The opt-in AuthenticatedSiemSpool adds HMAC-SHA256 sequence/predecessor integrity, one active plus seven historical zeroized keys, byte/record quotas and fail-closed restart/forgery/ordering/external-change behavior. Whole-tail checkpoints, multi-writer coordination, retention, delivery, retry, acknowledgement and external adapters remain operator work. |
| TOTP Multi-Factor Auth | 🟢 [Implemented: foundation] | Six-digit SHA-1 TOTP generation/verification with a ±1 time-step window, percent-encoded otpauth URI builder, and subject-bound single-use recovery-code verifiers. Enrollment, transactional persistence, rate limits, and account policy remain application concerns. |
| CSWSH Guard | 🟢 [Implemented] | Exact normalized scheme/host/port validation for WebSocket origins, with a fail-closed default for missing origins. Without an allowlist, the Origin must match the Host (or HTTP/2 :authority) and, when known from a trusted proxy or HTTP/2 :scheme, the request scheme. |
| HTML Sanitizer | 🟢 [Implemented: allowlist] | ammonia’s default allowlist for user-supplied HTML; sanitize_text escapes for HTML text and quoted attributes only. Allowed links and images can still reference external sites. |
| JSON Schema Guard | 🟢 [Implemented: bounded] | Rejects malformed, duplicate-key, over-2 MiB and over-32-level JSON; a route-scoped JSON Schema 2020-12 policy checks shape, not business rules. The global guard passes non-JSON media types through. |
| Honeypot and Decoy Routes | 🟢 [Implemented: local] | Exact trap paths are refused; HoneypotLayer bans the direct peer’s exact IP for 15 minutes by default, deception_trap_middleware only records. Bans are per process and avoided by address rotation. |
| LLM Prompt Filter | 🟡 [Implemented: heuristic] | Fixed English phrase, chat-token, Markdown-beacon and invisible-character checks on prompt/content/message JSON fields. Rephrased, translated or encoded instructions and indirect injection pass; legitimate text can be refused. |
Session Fingerprint (zero_trust) | 🟡 [Implemented: helper] | HMAC of User-Agent, Accept-Language and the client /24 or /64. All inputs are client-controlled or shared; expect false mismatches after browser or network changes. Not a zero-trust architecture. |
| Timing Guard | 🟡 [Implemented: coarse] | Pads responses to a minimum duration with jitter. Slower paths are not padded and the synthetic CPU work does not match a real password hash. |
| Canonical Server security stack | 🟡 [Partial] | CSP nonce identity is shared across Core and extended layers, but Core still owns the default Server CSRF/WAF/header/PII stack. Explicit composition is required. |
| Distributed Rate Limiting Evidence | 🟡 [Partial] | The Redis adapter is implemented, and the two-process deployment acceptance behind a Caddy proxy shares one Redis budget; eviction, failover and trusted-proxy deployment evidence remain required. The legacy no-argument RateLimiter::try_with_distributed still returns DistributedBackendUnsupported rather than guessing configuration. |
🔐 1. Rullst Vault (Authenticated AES-256-GCM)
FieldEncryptor provides authenticated encryption at rest (AEAD) with a
versioned envelope and keyring-assisted rotation. Applications can keep prior
keys readable while writing with a new key; deployment coordination, key
custody, data re-encryption, and retirement remain operator responsibilities.
Usage Example
use rullst_security::vault::FieldEncryptor;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let master_key = [0x42u8; 32]; // 256-bit cryptographic key
let sensitive_data = "user_ssn_123-45-6789";
// Encrypt with key ID and Additional Authenticated Data (AAD)
let encrypted = FieldEncryptor::encrypt_with_key_id(
sensitive_data,
master_key,
"key-2026-v1",
b"tenant-organization-id-42",
)?;
println!("Ciphertext Envelope: {}", encrypted);
// Decrypt and verify authentication tag and AAD
let decrypted = FieldEncryptor::decrypt_with_aad(
&encrypted,
master_key,
b"tenant-organization-id-42",
)?;
assert_eq!(decrypted, sensitive_data);
Ok(())
}
🛡️ 2. Runtime Application Self-Protection (RASP)
The RASP request inspector scrutinizes incoming requests before they reach your controllers:
#![allow(unused)]
fn main() {
use rullst_security::RaspInspector;
let is_sqli = RaspInspector::inspect_text("admin' OR '1'='1");
assert!(is_sqli);
let is_traversal = RaspInspector::inspect_uri("/files/../../../etc/passwd");
assert!(is_traversal);
}
Mount RaspSecurityLayer explicitly when the extended inspector is desired.
The default rullst-core::Server currently mounts the smaller Core WAF rather
than this layer; consolidation remains roadmap work.
The inspector matches case-insensitive signatures, raw and after one
percent-decoding pass, in the request target, every header except Cookie
and Authorization, and identity-encoded textual bodies up to 1 MiB. It does
not inspect multipart or binary bodies, does not undo double encoding, HTML
entities or SQL comments, and refuses ordinary text that contains a signature
(../, sleep(, /bin/sh). It reduces exposure to known payloads; it is not
an injection defense on its own.
🚫 3. Anti-Bruteforce Login Guard & Tarpit
#![allow(unused)]
fn main() {
use rullst_security::LoginGuard;
async fn apply_login_delay() {
let guard = LoginGuard::new(); // defaults: 5 failures, 15-minute local jail
// Record failure and apply the returned progressive delay in the async handler.
let delay = guard.record_login_failure("account:alice");
tokio::time::sleep(delay).await;
if guard.is_jailed("account:alice") {
println!("Identity is in the local Login Jail");
}
}
}
📱 4. Multi-Factor Authentication (TOTP MFA)
#![allow(unused)]
fn main() {
use rullst_security::{
build_otpauth_uri, generate_mfa_secret, generate_totp_code, verify_totp_code,
};
let secret = generate_mfa_secret();
let otp_uri = build_otpauth_uri("Rullst SaaS", "[email protected]", &secret);
let current_code = generate_totp_code(&secret);
// Validate 6-digit code submitted by user
let is_valid = verify_totp_code(&secret, "123456");
}
The secret must be encrypted at rest. Recovery helpers return plaintext codes only at enrollment and salted HMAC verifiers for storage; consume/delete must be one durable transaction. The application still owns replay/attempt limiting, enrollment confirmation, recovery UX, audit and clock-monitoring policy.
Rullst IoT 📡
“Embedded Sensor Protocols, Ed25519 OTA Gate & Edge Computing for Rust”
Important
This page describes the v13 prerelease, published as
13.0.0-alpha.4. Cargo never selects a prerelease on its own, so its dependency examples pin that version exactly. The v12 line (rullst-iot@12) reached end of life on 16 October 2026; see the release record. Use a path dependency only for checkout-local review. CommitCargo.lockfor reproducible builds.
rullst-iot provides high-assurance telemetry models, bare-metal #![no_std]
data structures and packet encoders, and a cryptographically verified
Over-The-Air (OTA) firmware update state machine.
Maturity: Experimental. The API may change between 13.x releases, and nothing in this crate has been validated on physical hardware or against a live broker. Experimental does not mean broken: the module status below separates working implementations from data models and contracts. See the maturity tiers.
⚡ Capability & Lifecycle Matrix
| Subsystem | Lifecycle Status | Description |
|---|---|---|
| Ed25519 OTA Manifest Gate | 🟢 [Implemented / Bounded] | Verifies a domain-separated signed manifest, target, firmware length/hash, and monotonic counter. A no_std store trait adds durable compare-and-set coordination; its concrete persistence, flashing, bootloader handoff, and hardware validation remain external. |
no_std Telemetry Models | 🟢 [Implemented / Bounded] | Allocation-conscious telemetry, digital-twin, and sensor models are available without std; board- and toolchain-specific builds must still be validated in the release matrix. |
| Protocol Frame Helpers | 🟢 [Implemented / Bounded] | MQTT 5 PUBLISH, RFC 7252 CoAP base requests, Modbus CRC, I2C frame packing, BLE GATT data models, and power-policy abstractions; these are bounded packet/state helpers, not network, bus, or radio drivers. |
| Experimental Fixtures | 🟡 [Simulated fixtures] | The opt-in feature exposes explicitly named deterministic MQTT formatting, HSM-byte, and PQC-byte fixtures. GPIO/I2C/BLE types are always-available state/frame helpers, not hardware simulators. |
| Native MQTT/CoAP Transport | 🔵 [Roadmap] | Connections, TLS/DTLS, broker negotiation, acknowledgement/retransmission state, subscriptions, block-wise transfer, and interoperability. |
| Hardware Security Module (HSM) | 🔵 [Roadmap] | Native secure-element driver interfaces (ATECC608A, TPM 2.0, STSAFE). |
Module status
Each module falls into one of four kinds:
- Working: complete for its stated, bounded scope and covered by tests.
- Contract: a trait your platform code implements; the crate supplies no implementation.
- Model only: data structures that hold state in memory. They drive no hardware, radio or network and act as placeholders for a platform adapter.
- Fixture: deterministic test data behind the
experimental-simulatorsfeature. Never use it in production.
| Module | Kind | What it does | What it does not do |
|---|---|---|---|
ota | Working + Contract | OtaManager verifies an Ed25519 signature over a domain-separated manifest that binds target, version, rollback counter, firmware length and SHA-256, then names the inactive A/B partition. RollbackCounterStore is the contract for durable compare-and-set of the counter. | No download, flash writes, bootloader handoff or concrete counter storage. The deprecated new, verify_signature and commit_update always fail. |
mqtt | Working (encoder) | MqttPublish encodes one MQTT 5 PUBLISH packet: QoS 0/1/2, retain, DUP, topic validation and a 1 MiB ceiling. | No CONNECT, SUBSCRIBE, acknowledgement state, socket, TLS or retry. |
coap | Working (encoder) | CoapRequest encodes an RFC 7252 GET/POST/PUT/DELETE request with Uri-Path and Content-Format options under a 1152-byte ceiling. | No UDP/DTLS, retransmission, block-wise transfer or response decoding. |
modbus | Working (narrow) | Modbus CRC-16 and a Read Holding Registers (0x03) RTU request frame. | No response parsing and no write-request builder (WriteSingleRegister is only an enum value). It does not check the protocol’s register-count limit and has no serial or TCP transport. |
i2c | Working (narrow) | Builds the byte layout of a register-read transaction, rejecting reserved or non-7-bit addresses and reads above 8,192 bytes. | No bus access. |
anomaly | Working | Classifies a reading against a fixed mean and tolerance (normal, warning beyond 1×, critical beyond 2×); non-finite values fail closed as critical. | No learned baseline or time-series analysis. |
twin | Working (in memory) | DigitalTwin stores readings, returns the latest per metric and serializes a JSON snapshot, refusing non-finite values. | No synchronization or transport. It keeps every ingested reading, so trim readings on long-running hosts. |
ui | Working | Renders an HTML-escaped sensor snapshot card. | No live updates or connectivity status. |
power | Working (policy) | Recommends a power mode and harvester state from hard-coded millivolt thresholds. | Does not control sleep, wake, charging or solar hardware. |
mesh | Model only | In-memory node list; best_relay picks the online node with the strongest recorded RSSI. | No discovery, routing, failure detection or mesh transport. |
ble | Model only | GATT service and characteristic structs. | No radio and no GATT server. |
gpio | Model only | In-memory pin mode and state. | No register or pin access. |
hsm | Fixture | SimulatedHsmDevice derives deterministic SHA-256 bytes. | Not an HSM: no key protection, signatures or MACs. |
pqc | Fixture | SimulatedPqcFixture derives deterministic hash bytes. | No ML-KEM/Kyber, no encapsulation and no quantum resistance. |
SensorTelemetry (crate root) is the shared telemetry data model. The ota,
modbus and anomaly modules also have fuzz targets, and CI builds the crate
for Cortex-M and RISC-V targets. Those builds are compile evidence, not hardware
tests.
Transports and hardware: use maintained ecosystem crates
For anything that touches a network, bus or radio, use a mature ecosystem crate and pass Rullst’s models or encoded bytes to it. Some starting points:
- MQTT on a host or gateway with
std:rumqttc. A full client encodes packets itself, so give it the topic and a serializedSensorTelemetrypayload. UseMqttPublishonly when you own the socket and need the raw PUBLISH bytes. - Async embedded firmware: Embassy for executors,
timers and networking (
embassy-net). - GPIO, I2C and SPI drivers: the
embedded-haltraits with your chip’s HAL. A HAL’swrite_readtakes the address and register directly, soI2cHelperframes are mostly useful for logging, tests and adapters that expect a raw byte layout. - Modbus RTU/TCP on a host:
tokio-modbus. - BLE from a desktop or gateway:
btleplug.
Rullst ships no adapter for these crates and does not test them together with
rullst-iot. They are suggestions; check each one against your target and
security requirements. Embassy integration (roadmap milestone M25) has not
started.
🛡️ Over-The-Air (OTA) Firmware Verification
The OtaManager enforces a fail-closed eligibility gate: its state machine does not produce an OtaCommit receipt before strict Ed25519 verification of the signed manifest. The RollbackCounterStore path also requires an exact, strictly increasing compare-and-set. The receipt selects the intended inactive partition; platform code must still flash, verify, implement durable storage, configure the bootloader, and recover safely from power loss.
Planned change.
OtaManager’s fieldscurrent_partition,statusandfirmware_versionare public and writable. A 13.x minor release will make them private and add getters of the same names, so read them but do not assign them.rullst-iotis Experimental, which allows this break; the changelog and the migration guide will list it.
The Cryptographic Invariant
[Signed Firmware Manifest]
├── Target Hardware ID: "stm32-sensor-node-v1"
├── Version String: "2.4.0"
├── Rollback Counter: 12 (Must be strictly > current committed counter)
├── Firmware Length: 131072 bytes
└── Firmware SHA-256: [32 bytes hash]
│
▼
[Ed25519 Strict Signature Check]
│
┌─────────┴─────────┐
Passed Failed
│ │
[Ready to Commit] [Revert & Reject]
Usage Example
#![allow(unused)]
fn main() {
use rullst_iot::{
BootPartition, OtaCommit, OtaError, OtaManager, OtaManifest, RollbackCounterStore,
};
fn process_incoming_ota<S: RollbackCounterStore>(
firmware_bytes: &[u8],
signature_bytes: &[u8],
provisioned_public_key: [u8; 32],
counter_store: &mut S,
running_partition: BootPartition,
) -> Result<OtaCommit, OtaError> {
// 1. Construct the expected manifest from the payload
let manifest = OtaManifest::from_firmware(
"esp32-sensor-node",
"2.0.0",
15, // Proposed monotonic counter
firmware_bytes
)?;
// 2. Load the last committed counter from the platform adapter
// and the bank the platform bootloader started
let mut manager = OtaManager::new_with_running_partition(
"esp32-sensor-node",
"1.9.0",
running_partition,
provisioned_public_key,
counter_store,
)?;
// 3. Cryptographically verify signature, target, and anti-rollback state
manager.verify_update(&manifest, firmware_bytes, signature_bytes)?;
// 4. Flash and read back this bank using platform code before commit.
let target_partition = manager.verified_target_partition()?;
// 5. Durable CAS succeeds before local state changes. Coordinate the
// receipt with the platform bootloader after this call.
let receipt = manager.commit_verified_update_with_store(counter_store)?;
debug_assert_eq!(receipt.target_partition(), target_partition);
Ok(receipt)
}
}
verified_target_partition and the receipt always name the bank opposite
current_partition. The v13 new_with_running_partition constructor takes the
bank the bootloader started; new_with_counter_store and
new_with_trusted_key assume PartitionA, so with them platform code must set
current_partition before verifying an update. Committing
does not change current_partition; until the platform reboots, a further
update verified in the same process targets the same inactive bank.
The store contract requires power-loss-safe persistence before returning
success. If a store reports a failure after committing, for example because an
acknowledgement was lost, a retry of that same manifest completes the commit
once the store reports, and a fresh load confirms, exactly its counter. A
stale manager, or a different image with the same counter, still receives the
conflict. The framework tests
restart/replay, transient retry, corruption and stale-writer conflict at the
adapter boundary, but those tests do not certify a particular flash, secure
element or board. A failure after the durable counter advances can require
platform recovery and a newer signed update.
🔌 Embedded Bare-Metal Telemetry (#![no_std])
rullst-iot exposes a no_std model layer intended for constrained microcontrollers. Compatibility is feature-, target-, allocator-, and toolchain-dependent and must be confirmed for the actual board:
#![allow(unused)]
fn main() {
use rullst_iot::SensorTelemetry;
fn sample_reading() -> SensorTelemetry {
SensorTelemetry::new(
"node-1",
"temperature_celsius",
24.5,
1_724_500_000,
)
}
}
MQTT and CoAP packet boundaries
#![allow(unused)]
fn main() {
use rullst_iot::{
CoapMessageType, CoapMethod, CoapRequest, MqttPublish, MqttQos,
};
let publish = MqttPublish::reliable(
"nodes/node-1/temperature",
b"24.5".to_vec(),
MqttQos::AtLeastOnce,
7,
)?
.encode()?;
let request = CoapRequest::new(
CoapMessageType::Confirmable,
CoapMethod::Post,
42,
[0x01, 0x02],
)?
.path_segment("telemetry")?
.content_format(50)
.payload(br#"{"temperature":24.5}"#.to_vec())?
.encode()?;
Ok::<(), Box<dyn std::error::Error>>(())
}
These helpers follow the local OASIS MQTT 5 PUBLISH and RFC 7252 CoAP packet shapes. They deliberately stop before any socket or session state; the transport owns security, identity, timing, correlation, retry and peer interoperability.
🔬 Experimental Simulators (experimental-simulators)
For local integration tests without physical hardware attached, enable the simulator feature:
[dependencies]
rullst-iot = { version = "=13.0.0-alpha.4", features = ["experimental-simulators"] }
This exposes SimulatedMqttPayloadFormatter, SimulatedHsmDevice, and SimulatedPqcFixture for deterministic sandbox execution.
Rullst Master Roadmap 🗺️
“The Path to the Ultimate Full-Stack Rust Framework” — an aspiration, not a guarantee
Rullst’s ambition is an asset. This roadmap preserves that ambition while separating what exists today from what is only a prototype, a research program, or a vision. An idea is never deleted merely because it is unfinished.
Our philosophy: “Security, Developer Experience and Performance, Architected for Humans and AI.”
Single roadmap source:
ROADMAP.mdis canonical.docs/src/roadmap.mdembeds it directly in mdBook instead of maintaining a divergent copy. The deeper evidence and decision record isdocs/src/capability-ledger.md. The completed v12 identity and evidence map live indocs/src/v12.md; this roadmap owns the v13 programme until a dedicated release checklist is approved.
Next twelve months (October 2026 – October 2027)
Intentions, not promises; dates follow the evidence gates described below.
- v13:
13.0.0-alpha.1(published as a prerelease on October 11, 2026),13.0.0-alpha.2(published as a prerelease on October 17, 2026, with the alpha.1 dogfooding fixes),13.0.0-alpha.3(published on October 24, 2026: PWA, Studio production monitor, OAuth providers),13.0.0-alpha.4(the candidate prepared on October 29–30, 2026 for a tag on October 31, and the 13.0 feature and public API freeze: a lean CLI withgreenbuilt in and the terminal assistant removed, one deploy path tested in CI, three starters, a documentation audit, aws-lc-rs as the single TLS provider andFromRowgenerated by#[derive(Orm)]), then13.0.0-rc.1with fixes and documentation only, driven by user feedback, then13.0.0stable with the maturity tiers applying from that release. - v12: end of life on 16 October 2026; 12.3.0 is the last release and the
v12branch is frozen. Its unpublished fixes (PR #500 and the RustSec updates) stay on the branch; new 12.x projects no longer resolve their dependencies after crates.io yanked thezipreleases its Studio needs. - Assurance: keep the OpenSSF Best Practices Silver badge (granted on 10 October 2026) current, mutation testing extended from the critical v13 code to the whole workspace, and an independent review prepared with the external audit kit.
- Selected programmes: the green software roadmap and the legal compliance roadmap.
Status language
[x] Implemented: a bounded, testable implementation exists. This never means that every imaginable provider or production environment is covered.[~] Partial: useful foundations exist, and the parenthetical says whether the remaining work is worthwhile and why.[ ] Not implemented: the idea is preserved, and the parenthetical says whether it is worth pursuing and under which conditions.[!] Do not promise: the absolute wording cannot be an honest framework guarantee; a narrower measurable goal is retained when useful.
Target windows are planning intentions, not release guarantees. Promotion to
[x] requires code, focused tests, truthful documentation, and the release gates
at the end of this document.
September 22 direction: stable v12.1 maintenance takes priority over publishing v13 to meet a date. September 26 is a review/handoff point, and development may continue in later sessions. The investment proposal groups M1–M41 into 25 themes to invest in or maintain and 16 to defer until justified, including M31’s separate programme. It preserves the historical ideas; it does not require every theme for 13.0.0 or remove maintenance from already supported capabilities. The same proposal flags twelve existing themes with a separate high-maintenance marker and offers eight bounded follow-up subtasks. Those overlap the original themes and are not added again to the milestone count.
The owner also adopted the v13 maintenance scope and extension strategy: retain common security and productivity foundations, limit Capital’s expansion, and assess separate lifecycles for specialized extensions. This decision refines investment and compatibility planning; it neither removes current capabilities nor changes package admission or the v12.1.1 candidate.
September 23 evaluation direction: the framework comparison plan defines reference projects, equivalent application tasks and measurements of effort, failures, performance and maintenance. Begin a small baseline after active v12.1.1 maintenance rather than waiting for all 25 A themes. The plan refines existing priorities; it adds no milestone, release gate or superiority claim.
Audit of the detailed crate roadmaps
The per-crate roadmaps are intentionally preserved as detailed design backlogs.
Some predate this status policy, so an old [x] can record the original author’s
milestone claim rather than today’s verified end-to-end contract. This table is
the current interpretation; the capability ledger
contains the evidence boundary and recommendation for the highest-risk claims.
| Detailed roadmap | What is verifiably implemented now | Partial, experimental, or not implemented |
|---|---|---|
rullst-ai | Guarded provider clients, deterministic mocks/eval corpus, bounded RAG, opt-in OpenAI-compatible SSE/cancellation, SQL conversational memory, guarded tools, authenticated audit export and adaptive evaluation orchestration. | Provider-native tool loops, first-party external retrievers, non-compatible streaming adapters and hosted-model conformance remain partial or application work. Local eval orchestration is not live-model validation. |
rullst-auth | Argon2, encrypted sessions, RBAC/policies, OAuth/OIDC re-exports, bounded application JWTs, opt-in SQLite revocation/passkey-device state, a custom ES256 passkey foundation and the 12.1 PostgreSQL/SQLite account-recovery and transactional-notice contracts. PR #223 admitted an optional v13 PostgreSQL ceremony manager with bound single-use challenges and database/process/browser evidence. | Bounded opaque-session inventory and sibling logout passed v13 source/package admission in PR #236; final release admission remains separate. Broader refresh/device-session workflows and normative WebAuthn conformance remain incomplete; applications own identity, roles, credential-owner/revocation/counter checks and deployed session policy. |
rullst-capital | Provider-neutral billing traits, Stripe and an experimental InfinitePay adapter, explicit offline mocks, canonical fail-closed webhook verification with Axum/Actix adapters, shared bounded webhook replay claims and team/workspace quotas over four relational protocols, Stripe coupon/trial contracts, billing scaffolding and analytics. | InfinitePay live validation, cross-system exactly-once/reconciliation and full tax/proration contracts are not implemented. v13 removed the other adapters, payouts and NFS-e; other gateways are application-owned adapters. |
rullst-connect | OAuth2/OIDC/social adapters, state/PKCE/nonce lifecycle, guarded refresh/revocation contracts, encrypted account-bound tokens and opt-in shared-local SQLite token persistence with generation CAS. | Live provider conformance, remote refresh leases/reconciliation, multi-host replication, SAML/SCIM/DPoP/JWE/mTLS and risk ML remain application or roadmap work. Broker adapters belong to Messaging. |
rullst-iot | no_std frames/telemetry, bounded MQTT 5 PUBLISH and CoAP request encoders, the Ed25519 OTA manifest gate, and a typed durable-counter CAS boundary with restart/retry/conflict proof. | Download, a concrete hardware-backed counter, flash/boot/rollback, MQTT/CoAP/LoRaWAN transports and session state, real hardware, HSM and PQC are not implemented; deterministic Simulated* types are experimental fixtures only. Keep the vision, but require target hardware and interoperability programs. |
rullst-mail | Core REST/SMTP/log/memory/mock drivers, failover, bounded attachment/CID serialization, scheduling foundations, mandatory security/deliverability pipeline, deterministic mocks, tenant resolution, tracking tokens, factories, background worker integration, opt-in bounded attachment inspection, shared-local SQLite suppression and minimized delivery observations. Version 12.1 adds Resend/Svix feedback verification and ACS Managed Identity, SendPulse, Mailjet and Mailtrap transports with explicit protocol boundaries. Version 13 keeps Resend, AWS SES, SendPulse and SMTP and removes the SendGrid, Postmark, Mailjet, Mailtrap and ACS transports (kept in 12.x); other providers are application-owned MailDriver transports. | A checked item does not prove provider acceptance or inbox delivery; provider limits may be tighter, the local inspector is not antivirus/CDR, and other provider feedback adapters plus multi-host suppression remain open. Compile-time mailables/CSS inlining, inbound MIME, AI dunning, DMARC/DKIM/S-MIME and Studio Mail Radar are not implemented; additional providers require a shared contract suite. |
rullst-messaging | Bounded envelopes and wire/trace codec, idempotent publication, competing consumers, leases/retry/DLQ, deterministic local broker, encrypted-content SQLite state and opt-in ORM outbox relay. | The standalone Redis Streams candidate has TLS/restart/fault/outbox evidence and passed hosted source/package admission in PR #236; see its contract. Other remote brokers and replication/failover remain roadmap work. Local durability does not provide cross-system exactly-once delivery. |
rullst-nexus | Fail-closed admin construction, generated metadata/forms, CRUD/search/pagination/batch actions, opt-in trusted-context tenant scope and transaction-coupled mutation audit. | Host authentication/tenant resolution, global-model/custom-route policy, immutable external audit, custom dashboards and a visual SQL builder remain application or roadmap work. |
rullst-orm | SQLx pools/dialects, Active Record/repository/query/schema foundations, fail-closed tenant scopes, strict DB modes, transactions, relations/soft deletes, audit/privacy, typed Turso primary, bounded MongoDB/DuckDB/SurrealDB adapters, Qdrant vectors and Redis native structures. | The transactional partial-update candidate passed native/archive-consumer and full hosted source admission in PRs #236 and #239. Several historical [x] entries remain partial or absent: transparent edge replication, universal external-search durability, autonomous schema/index changes, automatic graph traversal, Wasm drivers and PQC. The 45 historical claims are preserved in the immutable v12 audit; the current capability ledger owns their boundaries. |
rullst-security | Bounded honeypot, sanitizer/CSP, RBAC, HMAC audit chain, RASP/DLP, AES-GCM vault, headers, applied Login Jail tarpit, TOTP with SVG QR, CSWSH origin policy, strict JSON/log guards, file-backed SRI, CEF formatting, compatible unsigned and opt-in HMAC-chained bounded local SIEM journals, timing/prompt filters and fail-closed CLI evidence/SBOM/doctor tools. | “Autonomous”, live reputation/external SIEM delivery, A+ guarantees, zero-leak/zero-latency, certification and total OWASP/memory-safety claims are not established. Trusted whole-tail checkpoints, spool compaction/remote acknowledgement, CSRF WebSocket tickets/frame crypto, distributed rate limits/audit sinks, KMS/rotation, adaptive WAF, SQL firewall and all PQC/kernel/Wasm containment items remain partial or absent. |
rullst-studio | Verified-loopback developer UI, SQLx browser, supplied-OpenAPI playground, bounded queue history, ER diagrams, flags/config, authenticated trace ingestion, query heuristics and metadata-only Memory/Redis cache inspection. | Durable/OTLP trace storage, shared operator authorization, cross-process flag invalidation, Redis queue inspection and general database writes remain open. Query heuristics do not prove every N+1 or performance defect. |
This audit does not downgrade ambitious ideas merely because they are difficult. Capabilities that require continuous operations, homologation, hardware, or a separate release lifecycle can follow the Maybe SaaS incubation strategy instead of being forced into the framework core. It prevents a checkbox from becoming a production promise before the necessary code, tests, provider/hardware environment, and operational semantics exist.
Executive milestone tracker
| ID | Pillar and capability | Honest status and recommendation | Target window |
|---|---|---|---|
| M1 | DX: CLI empowerment and make:* generators | [~] Partial (worth finishing — the commands exist, but every generator/blueprint combination still needs a compiling temp-project matrix) | v12 hardening |
| M2 | DX: fast linkers, build tuning, and responsible hot reload | [~] Partial (v12 uses supervised process restart with build coalescing, retained service on compile failure and a development-only browser-refresh probe. The retained experimental DLL loader is not the public v12 workflow or a stable Rust ABI. Further build-latency improvements and optional state handoff need reproducible cross-platform evidence; universal sub-100ms reloads must not be promised) | Continuous / v13 research |
| M3 | DX: Axum/SQLx escape hatches, granular features, proc-macro diagnostics, and ejection | [~] Partial (worth improving as migration tooling — bare Core is now runtime-only, ORM/SQLite queues are explicit features, and the umbrella maps them; universal “zero lock-in” is still not worth promising because optional subsystems carry migration cost) | Next SemVer cycle |
| M4 | DX: make:resource and Ignition-style error console | [x] Implemented (scoped) — resource scaffolding and a local developer error console exist; autonomous mutation is evaluated separately in M37 | v12 hardening |
| M5 | DX: documentation hub (mdBook), OpenAPI, and AST TypeScript generation | [~] Partial (worth finishing — generators exist, but generated-project and serialization contract tests are still needed; AST inference is not a complete API contract) | v13 |
| M6 | ORM: Active Record, repository pattern, seeders, and Turso/libSQL vision | [~] Partial (SQLx foundations and the bounded Turso-primary Hrana transport/matrix exist; relation/hook/auto-diff parity and transparent synchronization do not) | v13 |
| M7 | Edge/data: portable Wasm request/response runtime, distributed data, and autonomous upgrades | [~] Partial (worth the portable edge runtime; distributed replication should use vendor-specific semantics, and autonomous upgrades are not worth enabling without signed artifacts, rollback, and operator approval) | v13 research |
| M8 | ORM/AI: intent-based modeling and self-optimizing production indexes | [ ] Not implemented (worth an advisory, explain-and-approve implementation — automatic production DDL without review is not worth the operational risk) | v13 research |
| M9 | Auth: local auth, OAuth/OIDC, TOTP, passkeys, and WebAuthn | [~] Partial (bounded application JWT policy, shared local revocation and opt-in PostgreSQL passkey ceremonies exist with hosted/database/browser evidence; complete refresh/recovery/session flows and normative WebAuthn conformance remain incomplete) | v13 |
| M10 | Security utilities: mail, DTO validation, rate limiting, and Shield | [~] Partial (worth completing — local controls and mail transports exist, while distributed rate limiting and some provider invariants require real backends and conformance tests) | v13 |
| M11 | SaaS: hardened Nexus, Omni vision, billing, and entitlements | [~] Partial (worth building in bounded modules — Nexus and billing foundations exist, but Omni, uniform live gateway coverage, and declarative entitlements are not complete) | v13+ |
| M12 | Defense in depth: RASP/WAF, Vault, honeypots, HMAC audit, secure headers, Login Jail, DLP, TOTP, fingerprinting, CLI inspection, and Threat Radar | [~] Partial (worth continuous hardening — concrete controls exist, but they do not prove universal OWASP coverage, zero leakage, external intelligence, or certification) | Continuous |
| M13 | Post-quantum web architecture, rullst-quantum, NIST PQC, and sandboxed Wasm plugins | [ ] Not implemented (worth later only for a concrete protocol and threat model, using audited primitives; home-grown “quantum-safe” crypto is not worth implementing) | v13 research |
| M14 | Frontend: HTMX-first SSR and Leptos/Dioxus interoperability | [~] Partial (worth improving — HTMX/HTML support is real, while the current Leptos/Dioxus types are compatibility wrappers rather than full framework integrations; “zero bundle” is a selectable architecture, not a universal guarantee) | v13 |
| M15 | Runtime: queues, cache, scheduler, multi-stage Docker, and brokered messaging | [~] Partial (bounded Core Memory/SQLite/Redis foundations plus rullst-messaging envelopes, idempotency, groups, leases, retry/DLQ, deterministic broker, contract suite and durable local SQLite state exist; the Redis Streams profile passed source admission in PR #236 and ships in the 13.0.0-alpha.1 prerelease; replication and RabbitMQ, Kafka, NATS, SQS/SNS, GCP Pub/Sub and Pulsar adapters remain future work) | Foundation v12; remote adapters v13+ |
| M16 | Wasm islands and #[client_component] | [~] Partial (the bounded #[server_function] transport is now implemented over rullst.client v1 with a generated Axum route, Wasm caller, compile diagnostics and native/Wasm/scaffold evidence; island hydration, packaging, real-browser interoperability and a stable component ABI remain open) | v13 |
| M17 | Real-time, object storage, media, and cargo rullst pkg | [~] Partial (WebSocket/SSE and local storage foundations exist. The Bunny Stream lifecycle, first published in the 13.0.0-alpha.1 prerelease, passed hosted/browser/archive source acceptance in PR #227; real provider/CDN interoperability remains unvalidated. Optional S3/R2 private operations passed source/package admission in PR #236; final release and provider interoperability remain unvalidated; image processing and a production package-registry contract remain open) | v13+ |
| M18 | LiveView-style server-driven UI and make:live | [~] Partial (an opt-in typed recovery candidate adds mandatory authorization, full snapshots, revision conflicts, bounded connections/actions and actual Chromium restart/disconnect evidence; source/package admission passed in PR #236; final release admission remains separate. Legacy components/scaffolds stay separate; DOM diffing, broadcast and automatic migration remain open. See the supported contract) | v13 |
| M19 | AI/telemetry: Radar, agent tool schemas, spans, and Prometheus /metrics | [x] Implemented (bounded) — local telemetry and export surfaces exist; unavailable sources must remain unavailable rather than becoming invented values | v12 hardening |
| M20 | Persistence: zero-copy event streaming and immutable ledger engine | [ ] Not implemented (interesting but lower priority — worth implementing only after defining persistence, consistency, recovery, and verification semantics; the HMAC audit chain is not a distributed ledger) | v13 research |
| M21 | Omni-frontend protocol and mobile hypermedia bridge | [~] Partial (the shared rullst.client v1 envelope exists; 13.0.0-alpha.3 retires the Tauri shells and the native offline-state foundation in favour of installable PWAs: make:pwa, a Google Play Trusted Web Activity with Digital Asset Links, and Microsoft Store packaging with PWABuilder. Native capabilities, physical-device evidence and store publication remain open) | v13 research |
| M22 | Agentic DevOps and autonomous infrastructure provisioning | [~] Partial (worth keeping as human-reviewed recommendations — telemetry advice exists; unattended infrastructure mutation is not worth enabling by default without preview, scoped credentials, audit, rollback, and policy) | v13 |
| M23 | Polymorphic core and auto-healing runtime/database | [~] Partial (worth keeping as diagnostics — a schema-error suggestion helper exists; automatic code/schema mutation is not worth enabling by default without validated plans, approval, and rollback) | v13 |
| M24 | Embedded IoT: no_std frames and an Ed25519 OTA manifest gate | [~] Partial (the frame/MQTT-PUBLISH/CoAP-request encoders, verification foundation and durable-counter CAS adapter contract exist; download, a hardware-backed store, flashing, boot slots, HSM/PQC, and transport interoperability do not) | v12 foundation / v13 integrations |
| M25 | Async embedded IoT with Embassy | [ ] Not implemented (worth implementing after transport and hardware traits stabilize, because executor integration before those boundaries would create churn) | v13+ |
| M26 | Guided VM/VPS deploy with Caddy | [~] Partial (worth hardening — cargo rullst deploy writes a Dockerfile, compose.yaml and Caddyfile for a Linux VM with Docker or Podman, with guides for Hetzner Cloud, DigitalOcean, Oracle Cloud and Google Cloud; 13.0.0-alpha.4 removed the Fly.io, Render and Railway targets. “One click” and zero downtime are not framework guarantees because credentials, DNS, migrations, health, and rollback remain operator concerns) | v13 |
| M27 | Kubernetes manifest scaffolding and /health//ready probes | [x] Implemented (scaffolding scope) — generated manifests remain deployment inputs that operators must review | v12 hardening |
| M28 | Compile-time DI and Inject<T> | [x] Implemented (foundation) — the typed container exists; “zero cost” remains a benchmarkable goal rather than a guarantee | v12 hardening |
| M29 | Scalar playground at /docs and OpenAPI generation | [~] Partial (worth finishing — the UI/router/generator exist, but full OpenAPI fidelity requires typed schemas and validation rather than syntax inference) | v13 |
| M30 | Tonic/gRPC and Protobuf scaffolding | [ ] Not implemented — removed (13.0.0-alpha.4 removed the make:grpc starting-service generator to keep the CLI lean; gRPC stays possible with an application’s own tonic setup, and a supported rullst-grpc crate is not planned) | v13 |
| M31 | Aerospace, autonomous vehicles, robotics, and defense (rullst-orbit / rullst-auto) | [ ] Not implemented (extraordinary, but not worth placing inside the web-framework Core; consider a separate safety-critical project only after hardware, standards, certification, and governance exist) | Separate future program |
| M32 | Architecture: first-class Axum/Tower escape hatches and precise proc-macro diagnostics | [x] Implemented (bounded) — router conversion/interoperability and syn::Error diagnostics exist; continue compatibility tests | v12 hardening |
| M33 | SaaS: #[rullst::gate] and GateGuard declarative entitlements | [~] Bounded foundation (typed current-state plan authorization and its authenticated SaaS consumer passed PR #221; the proposed attribute/guard syntax and broader declarative entitlement model remain open) | v13 |
| M34 | Multi-target SDK generator for TypeScript, React, Dart, and Swift | [~] Bounded TypeScript profile (one explicit OpenAPI 3.1 profile generates Rust codecs and a strict TypeScript HTTP client, with hosted consumer/archive acceptance in PR #221; React, Dart, Swift and broader schemas remain open) | v13+ |
| M35 | Distributed OpenTelemetry trace-waterfall visualizer in Studio | [~] Partial (Core/facade now has a minimized OTLP propagation/export candidate with local standard-collector TLS and independent-process ancestry evidence; source/package admission passed in PR #236; final release admission remains separate. Studio keeps its separate authenticated ingestion/local viewer; a unified OTLP-backed waterfall, durable storage and cross-host clock/skew presentation remain open. See the tracing profile) | v13+ |
| M36 | Natural-language-to-SQL Studio data copilot | [ ] Not implemented (worth a read-only, explainable assistant with schema allowlists, parameterization, preview, limits, and approval; autonomous production writes are not worth the risk) | v13 research |
| M37 | One-click AI error-console autofix | [~] Partial (the development error page no longer edits files and makes no network request. The server keeps a bounded, expiring, header- and body-free panic context that only a direct loopback request can read. The terminal assistant that fixed it (cargo rullst ai fix) was removed in 13.0.0-alpha.4; the page’s “Ask your AI assistant” panel now gives a copyable prompt and the recorded-context path to the developer’s own assistant, and the dead /_rullst/autofix endpoint (410 Gone) points there. Still open: a one-click flow, an edit audit trail beyond the checkpoint ref, evidence of fix quality with live providers, and the hot-reload server, which does not record errors) | v13 |
| M38 | In-memory/local-NVMe SQLite read replicas with background synchronization | [ ] Not implemented (worth vendor-specific adapters when demanded; generic “transparent replication” is not worth claiming because consistency and failover semantics belong to the selected database) | v13 research |
| M39 | Optional self-hosted Rullst Gateway and load balancer | [ ] Not implemented — abandoned (decided on 8 October 2026: not worth building. A Rullst-owned rullst-gateway crate/binary will not be developed. Self-hosted deployments use an established reverse proxy configured by generated files instead: cargo rullst deploy writes a Caddyfile and the two-replica deployment contract exercises Caddy; generated nginx configuration is planned. Readiness/drain signals stay in rullst-core for any proxy to consume. Parity with a managed global cloud load balancer remains external infrastructure. The idea stays recorded here and in the [ ] count.) | Abandoned (replaced by generated proxy configuration) |
| M40 | Isolated programming labs and learning-game execution | [~] Experimental implementation — bring your own runner (experimental rullst-labs contracts, in the 13.0 release inventory, provide durable submissions, exact grading, cancellation, recovery and retention, plus a documented controller contract and a non-executing example controller. The separately deployed Linux Rust/Wasmi rullst-labs-runner candidate passed 26 isolated checks and source admission in PRs #228 and #239, but was removed from the 13.0 workspace on 30 September 2026; its source remains in git history. Applications deploy and review their own runner; final release acceptance of rullst-labs remains pending. The web process never executes learner code or receives a container control socket. Broader language packs and offensive CTF infrastructure remain separate; see the dedicated roadmap.) | v13 research/foundation |
| M41 | Privacy defaults and proportional age assurance | [~] Initial foundation (opt-in rullst-privacy (first published in the 13.0.0-alpha.1 prerelease) age policies, signed evidence, asynchronous replay claims, trusted-clock rechecks, shared-local SQLite and optional PostgreSQL storage across application hosts with focused real-database tests; native declarations, versioned optional consent and generated SaaS preference/profile-export journeys have focused evidence (the LMS consumers were retired with the complete Academy scaffold). The package candidate adds optional facade features and passed hosted/archive source acceptance in PR #221; final release admission, live age providers, guardian verification, broader rights/retention and reviewed regional profiles remain open. See the privacy roadmap. No automatic worldwide compliance claim.) | v13 P0 |
Quantified planning horizon through v13
This inventory counts the status labels of the canonical long-term milestone programme. It includes v13 research and v13+ ambitions, not just features selected for 13.0.0 by 26 September. It measures neither release readiness nor the percentage of implementation effort already completed.
The label counts below were checked on 29 October 2026 against M1–M41. They include v12 hardening, continuous, next-SemVer, v13 and v13-research rows. M31 is excluded because the tracker explicitly assigns aerospace/autonomous/defence work to a separately governed future programme rather than the general v12/v13 framework suite. Detailed crate-roadmap checkboxes are not added again: they overlap with and decompose these canonical milestones, so a raw sum would double-count work.
| State | Milestones | Share of the 40-milestone horizon |
|---|---|---|
[x] bounded completion | 5 | 12.5% |
[~] useful but incomplete foundation | 27 | 67.5% |
[ ] not implemented | 8 | 20.0% |
| Total in the framework programme | 40 | 100% |
M39 stays in the [ ] row: it was abandoned on 8 October 2026 rather than
implemented, and the roadmap keeps abandoned ideas recorded. M30 moved from
[~] to [ ] when 13.0.0-alpha.4 removed the make:grpc generator.
There are 35 milestones without strict closure (87.5% of the labels). A partial milestone can contain substantial published functionality and years of remaining ambitions; these rows do not have equal size. The former calculation that assigned half credit to every partial row was an arbitrary index, not a measurement of completed code or remaining work. Do not use it to claim that a release implements a particular percentage of the entire framework vision.
Progress toward 13.0.0 is tracked through concrete deliverables and their acceptance evidence in the dated delivery plan. The September 22 publication candidate includes standalone Supervision and Media alongside Privacy; Labs joined on October 8, 2026, for twenty packages. All twenty were first published as the 13.0.0-alpha.1 prerelease on October 11, 2026; final release acceptance is still required; Labs remains experimental and ships contracts only, because its runner candidate was removed from 13.0. Compatible roadmap expansions can continue in 13.1 and later minors; they are not automatically deferred to a new major. Checking these labels does not constitute a fresh implementation audit of every underlying capability. Provider accounts, physical hardware, store acceptance, fiscal homologation, independent audits and research-grade cryptography cannot be completed by repository code alone. Do not add these counts to the historical-claim campaign or the v12 release checklist because those inventories substantially overlap.
AI-native vision, without absolutes
The original goal of becoming an AI-native Rust framework suite is preserved as a design ambition, not a historically provable “first” claim. The dedicated AI maintainability and project-building roadmap defines the v13 acceptance work for generated instructions, bounded context, golden tasks and reproducible model evaluation.
- “Zero Runtime Magic, Pure Compilation”: derives, typed routes, and compiler diagnostics can make AI-assisted changes easier to inspect. (Partial and worth pursuing as an architectural preference; literal zero magic, “zero hallucinations,” and instant correction are not promises any framework can make.)
- Context-rich scaffolding: generated projects should receive a maintained
AGENTS.md/AI ruleset describing the actual selected blueprint. (Partial and worth implementing;cargo rullst newandgenerate:ai-contextwrite commonAGENTS.mdinstructions when absent plus a bounded project map, not a blueprint-specific profile. Do not document.ai-rulesor.cursorrulesas generated until the generator and snapshots prove it.) - Structured system discovery: a versioned schema should expose active
routes, controllers, models, policies, and source locations. (Partial and
worth completing;
inspect schemaprints a statically derived ORM model schema andinspect routes --jsonemits recognized route declarations as versionedrullst.cli-routes.v1JSON, but controllers, policies, source locations and freshness must become an end-to-end contract.)
Preserved extraordinary capability decisions
These items were previously easy to mistake for shipped functionality. They are kept deliberately, with the opinion requested for each gap. The capability ledger contains the more detailed evidence and acceptance boundaries.
Architecture and product-contract ambitions
- Runtime-only Core with optional ORM (implemented in current hardening —
bare Core no longer selects SQLx/ORM,
ormandqueue-sqliteare independent, Studio/Nexus opt in explicitly, and the application umbrella retains ergonomic database defaults). - One canonical security stack (partial — worth treating as high priority;
keep policy/middleware in
rullst-securityand only minimal bootstrap contracts in Core so WAF, headers, and telemetry cannot drift). - Static dispatch everywhere (partial — not worth forcing absolutely; generic fast paths are valuable, but runtime-selected providers legitimately need a documented dynamic-dispatch boundary).
- Every production source file below 500 lines (partial — worth continuous responsibility-based refactoring, but it is a design target rather than a release claim and large test fixtures may need a looser limit).
- Uniform
#[non_exhaustive], fallible builders, andimpl Into<String>(partial — worth completing incrementally under SemVer review; a mechanical mass rewrite is not worth breaking consumers). - Zero lock-in, zero panic/crash, zero latency/allocation, 100% memory safety,
and 100% Pure-Rustls (
[!] Do not promise as absolutes— migration tools, scoped zero-panic linting, benchmarks, a tiny documented unsafe allowlist, and a feature-specific transport inventory are all worth maintaining). - Framework-wide “production-ready” badge (
[!] Do not promise as one boolean— worth publishing stability per crate/capability because routing can be stable while live payment and hardware integrations remain unavailable. The v13 maturity tiers now label every crate Core, Extension or Experimental and define each promise). - A first-party load balancer embedded in every application (
[!] Do not make the default— application serving and edge proxying need independent failure, upgrade and privilege boundaries. The opt-inrullst-gatewayresearch (M39) was abandoned on 8 October 2026 in favour of generated Caddy/nginx configuration for established proxies. Matching a managed cloud load balancer’s global infrastructure or SLA is not a repository-code claim). - Static competitor matrix claiming other frameworks lack capabilities
(
[!] Do not maintain without dated sources— comparative research and a reproducible benchmark repository are worthwhile; timeless absence claims are not).
Security, identity, and compliance
- Compliance-ready defaults for the main jurisdictions (EU, US, Brazil, Asia) (planned — building blocks, generated defaults and a per-regulation guide reviewed by counsel; never an automatic legal certification. See the legal & regulatory compliance roadmap).
- Full WebAuthn/FIDO2 conformance (partial — absolutely worth completing before a stable passkey claim, preferably with an audited library or normative conformance suite).
- Zero-downtime key rotation and Cloud KMS (not implemented end to end — worth implementing through provider-neutral envelope/key-version contracts and named KMS adapters, not by embedding custody in the framework).
- Adaptive WAF and eBPF kernel threat containment (not implemented — worth research only as opt-in, platform-specific defense in depth; not worth making a portability or complete-protection promise).
- Anti-timing user-enumeration guard and Prompt Shield v2 (implemented foundations — worth keeping and testing, but timing equalization and heuristic prompt filtering cannot guarantee elimination of every side channel or injection technique).
- External reputation feeds, verified audit feeds, and SIEM delivery for Threat Radar (partial — worth pluggable connectors; never render a source as healthy or verified unless it is connected and current).
- Studio automatically stripped from every release at zero cost (
[!] Do not promise— explicit feature selection and route mounting are worth documenting; a universal debug/release assumption is not). - Distributed rate limiting and durable tamper-evident audit storage (partial/not implemented — worth pluggable Redis and append-only sink backends with atomicity, tenant namespacing, retention, and verification tests).
- Automated SBOM, SPDX/CycloneDX,
cargo-vet, signed provenance, and advisory governance (partial — worth making release gates; not equivalent to SLSA Level 3 or organizational certification without independent evaluation). - Loom/Shuttle, Kani/Miri, mutation, fuzz, and unsafe governance (partial —
worth scoped blocking suites plus a reviewed
cargo-geigerinventory; a full mathematical proof of the whole framework is not worth claiming). - An IDOR scanner that proves authorization (
[!] Do not promise proof— the AST scanner is worth keeping as a heuristic warning tool, paired with route-level ownership and cross-tenant negative tests). - DevSecOps git-hook installer (partial —
hook:installwrites pre-commit and Conventional Commit hooks; worth adding backup/idempotency/permission tests, while CI remains authoritative because local hooks are bypassable). - Automatic SOC 2/ISO/FedRAMP PASS reports (
[!] Do not implement as an unconditional verdict— evidence export is worthwhile; certification covers an organization and deployment, not a crate).
Fiscal, payments, messaging, storage, and mail
- Live NFS-e Nacional (removed from the framework in v13 — the local preparation module was never validated with a real municipality; fiscal work may return only as a separate, dedicated product with official homologation and independent crypto validation).
- Uniform live support across many gateways (
[!] Do not promise— v13 keeps Stripe and an experimental InfinitePay adapter; other gateways are application-owned adapters on the provider traits, and adapter names must not imply every payment, subscription, portal, tax, and webhook method exists). - Static fee/settlement/tax tables and “zero-cost invoicing” (
[!] Do not promise— transparent links to current provider terms are worthwhile, but framework docs cannot erase certificate, accounting, infrastructure, support, compliance, or changing commercial costs). - Durable cross-instance webhook replay/idempotency (partial — worth a pluggable database/Redis uniqueness contract before multi-instance production billing).
- RabbitMQ, Kafka, Redis Streams, NATS JetStream, SQS/SNS, and GCP Pub/Sub
(Redis Streams candidate passed source admission in PR #236 — see the supported profile. Other listed adapters remain unimplemented.
The separate
rullst-messagingcrate supplies the common contract; add providers only after their delivery semantics pass provider-specific restart and fault evidence). - S3 and Cloudflare R2 (v13 candidate in the 13.0.0-alpha.1 prerelease — the optional Core/facade
storage-s3feature implements bounded private object operations with official signing, explicit credentials and deterministic mocks. Hosted source/package admission passed in PR #236; final release and provider interoperability remain outstanding; see the supported contract. Server-mediated resumable multipart uploads are the separate opt-instorage-multipartcandidate; direct presigned uploads and image resizing remain future increments). - Mailgun, Brevo, MailerSend, Plunk, and Scaleway transports (not implemented — worth demand-driven adapters only when each has a maintainer and passes the shared offline/live mail contract suite).
Efficiency and sustainability
- Measured green-software reports and efficient defaults (planned —
the Experimental
cargo rullst greencommand (measured figures only, carbon as a labelled SCI estimate per ISO/IEC 21031:2024), a reproducible cross-framework benchmark, tuned release and HTTP caching defaults, carbon-aware deferrable jobs. Reports state their method and limits; no unsubstantiated “greener than” claims. See the green software roadmap).
IoT, edge, AI, and critical systems
- MQTT 5, CoAP, Sparkplug B, CAN/J1939, LoRaWAN, GPIO/I2C, real firmware download/flashing/rollback, and hardware-in-the-loop CI (not implemented — worth separate transport and target-hardware packages after named boards and interoperability environments are selected).
- Hardware HSM/secure-element and NIST ML-KEM/PQC backends (not implemented; simulators are experimental — worth audited adapters for named hardware and protocols, never home-grown crypto presented as secure hardware).
- Autonomous AI admin, NL-SQL writes, self-healing code/schema, and DevOps mutation (not implemented as a safe production contract — read-only advice, dry runs, and human-approved changes are worthwhile; default autonomous production mutation is not).
- Native JSON Schema enforcement on every LLM (partial — capability-typed
support is worth completing; parseable JSON must remain distinct and providers
that cannot enforce a schema should return
UnsupportedCapability). - Any local model over any arbitrary HTTP API (
[!] Do not promise— named Ollama and a capability-declared OpenAI-compatible local/cloud adapter are implemented, while arbitrary APIs differ in authentication, streaming, tools, schema, and error semantics and use the public provider trait). - Automatically air-gapped/zero-leak AI (
[!] Do not promise— local endpoints can be useful, but the host network, logs, model runtime, and telemetry determine the real data boundary). - Aerospace/autonomous/defense framework (not implemented — the research is inspiring, but it is not worth conflating safety certification with web framework quality; incubate it independently if expertise, hardware, and governance become available).
Audited execution plan
Phase 0 — containment and truthful boundaries
- Keep unfinished IoT integrations, unconfigured S3/R2, live InfinitePay and
other absent provider paths fail-closed with typed
Unsupportedresults. - Keep Nexus fail-closed, generated credentials absent, production configuration validated, webhook secrets mandatory, local storage confined, and the release workflow blocked until its dependency order and evidence agree.
- Label every capability implemented, partial, experimental, not implemented, or intentionally unsupported; never delete the vision to obtain truthful docs.
Phase 1 — kernel security and reliability
- Complete environment precedence, atomic/fallible DB initialization, APP_KEY policy, WebAuthn conformance, content-aware DLP/PII, signed-webhook composition, trusted proxies, tenant isolation, CSWSH, bounded workers, scheduler shutdown, and the production-path zero-panic policy.
- Extend M10/M27 with cloud/VPS application-protection evidence using the existing
Security/Core/CLI boundaries: one trusted-proxy and shared-rate-limit deployment
profile, bounded resource policies and read-only diagnostics that redact secrets.
Keep host/network administration external. This is a conditional v13 increment
after supervision and shared passkey state, ahead of a new gateway; see the
delivery plan. PRs #224 and #225 admitted its
first increments, the Caddy/Redis deployment contract and the offline
deploy:doctor; the rest is not implemented merely by appearing here, and it adds no separate milestone or crate.
Phase 2 — product integrity and scaffolding
- Compile all generated projects in temp directories; enforce server-side Nexus policy; use real or explicitly unavailable Studio telemetry; and keep offline mocks deterministic without allowing live endpoints to fail open.
Phase 3 — architecture and contract
- Keep the new Core/ORM feature boundary regression-tested, consolidate the
canonical security stack, standardize public API evolution, and split OAuth
identity from future messaging adapters. The umbrella feature map is now
complete and must remain covered by the isolated per-feature rows of
.github/check-feature-boundaries.sh, which fails when a public umbrella feature has no row. - Implement ambitious providers only where a maintainer, conformance suite, and real interoperability environment exist.
Phase 4 — release engineering
- Require formatting, strict Clippy, full workspace tests, exclusive DB-feature checks, generated-project checks, fuzz tiers, unsafe review, package preflight, SBOM/advisory evidence, provenance, and topological publishing for the exact tag.
Release strategy
v13 execution priorities
This section records the execution order set in September 2026. Since then the v12 line reached end of life (16 October 2026), so its v12 maintenance steps are historical, and 13.0.0-alpha.4 is the 13.0 feature freeze; the current plan is under Planned after 13.0.0-alpha.4.
The Verus verification pilot is planned supporting work for the privacy, authentication and payment contracts in M41, M9 and M11. Begin with a small production age-policy scope, then evaluate Auth and Capital; keep proofs beside their modules and start with a pinned manual workflow. Reproducibility, reviewed assumptions, negative controls, consumer compatibility and measured CI cost precede mandatory checks on affected v13 changes. This does not add a public crate or a release requirement.
Execution order: verification efficiency, then the compatible 12.1.0 update experience, then concentrated v13 product development. The published v12 packages remain immutable; important maintenance fixes remain independently reviewable. Carry the compatible updater and applicable fixes into v13 without merging unrelated breaking work into the v12 line. The phased efficiency plan is in WORKFLOWS.md. Application API changes still belong to the appropriate release line.
The compatible 12.1.0 update experience is published. The publication record links its immutable source and successful release evidence. New capability work belongs to v13; compatible security fixes were eligible for v12 maintenance until its end of life.
PR #218 admitted the published
12.1.0 baseline and the SQLite/PostgreSQL replay foundation to protected v13
at 7f48d882. Subsequent source admissions are recorded individually in the
delivery plan. Keep later applicable stable
fixes synchronized through reviewed changes; a development branch name alone
does not prove that it includes them.
The published 12.1.0 source at b62390b4 and its documentation closeout
were carried into that v13 integration: account mail, one-time Stripe contracts,
machine endpoints, strict driver isolation, native CLI installation and project
update/recovery join the earlier SaaS and Android corrections. The
age-assurance package (first published in 13.0.0-alpha.1) and v13
Labs/privacy/Verus plans remain separate.
Each later increment requires its own CI evidence; stable-release results do
not certify the combined v13 source.
The delivery plan and evidence retains the bounded acceptance criteria and original calendar. The September 22 priority revision removes a mandatory publication date. Neither document turns planned capabilities into shipped claims.
On September 21, the owner selected shared PostgreSQL consent and single-use email login links as the next two implementation priorities through September 23. The additional-priorities section records their acceptance boundaries and five subsequent additions approved for the implementation queue. These entries do not claim implementation or expand the validation and publication dates.
| Order | Outcome | Acceptance boundary |
|---|---|---|
| P0 — verification efficiency | Shorter local and hosted feedback, with measured cold/warm build and queue times | Compare test inventories; select affected crates and their consumers; preserve broad scheduled/release checks and a full-run fallback for unknown changes. Bind reusable evidence to source, dependencies, tools and policy. Prove that security, workflow, manifest and generator changes cannot silently skip required checks. |
| P0 — safe update experience | Discover, prepare, verify and approve CLI/project updates through one guided entry point | The compatible opt-in 12.1.0 flow is published with declared native installation and isolated project acceptance. Carry it into v13 and add explicit major-version migration rules and consumer acceptance; publication of 12.1.0 does not establish 12→13 compatibility. The v13 CLI now reports v12 → v13 source findings (rullst-upgrade-rules-v4), each linked to its migration row. |
| P0 — SaaS maintenance | Contain affected live operations and repair confirmed examples feedback | Follow the 15-finding triage plus Nexus configuration fix. Compatible v12.1 fixes remain independently deliverable; new payment contracts need durable ownership/idempotency and provider acceptance evidence. |
| P0 — privacy and age assurance | Reusable privacy defaults and age checks proportionate to risk across SaaS, LMS and examples | Complete the M41 delivery plan before additional learning/monitoring features. Reject production mocks and unverifiable results; minimize data, offer alternatives and review jurisdiction profiles. No automatic legal certification. |
| P1 — navigable API documentation | Developers can find a capability, understand its contract and run a realistic example | Connect versioned Rust API references, task-based guides and tested REST examples. Document errors, feature flags, security boundaries and migration paths alongside each prioritized API; see the documentation plan. |
| P1 — Installable application delivery | Predictable PWA installation, store packaging and guidance | Diagnose manifest, icon, HTTPS, service-worker and Digital Asset Links mistakes, distinguish a prepared package from an accepted store listing, and test install, update, navigation and interrupted networks. Device and store acceptance need their own evidence. |
| P1 — coherent application contracts | One clear path for sessions, ownership, tenant context and typed client APIs | Consolidate existing Auth/Core/Security boundaries, complete selected session/passkey flows and validate API/SDK serialization. Preserve explicit configuration and negative authorization tests. |
| P2 — interactive learning products | Server-authoritative progress, gamification and isolated programming exercises | Build on the LMS example (examples/lms, the former LMS starter), which replaced the complete Academy scaffold; version grading rules, persist idempotent results and prove tenant isolation. Use the rullst-labs contracts with an application-owned runner (the rullst-labs-runner candidate was removed from 13.0); untrusted execution stays outside the web process. |
| First conditional extension — transparent supervision | Optional rullst-supervision, first published in the 13.0.0-alpha.1 prerelease, now implements bounded exam/learner and parental contracts and transparent observations; PRs #222/#226 and the repaired archive gate provide source evidence. Its generated LMS journey (make:supervision) was removed with the complete Academy scaffold | Deliver a generated LMS journey with independently authorized guardians/reviewers, visible sessions, explicit permissions, revocation, bounded events/retention and application-side restrictions. Automated client observations do not prove misconduct or justify automatic penalties. Device-wide controls and camera inference require separate platform contracts; see the delivery plan and SST boundary. |
| P3 — selected integrations | One complete real-provider or broker journey at a time | Add an adapter only with a concrete product need, protocol/failure tests, documented limits and an available acceptance environment. The self-hosted gateway (M39) is abandoned; proxy support means generated configuration for established proxies. |
This is an execution order, not a promise that the entire historical backlog fits one month. Prefer completed user journeys and measured acceptance criteria over increasing the number of crates. The workflow roadmap retains the assurance experiments; move a proposal into the implementation column only after its code and evidence exist.
Safe update experience
Status: published in 12.1.0 for the documented compatible-update scope. The goal is the easiest practical update journey without hiding risk: one guided entry point, a clear plan, minimal repeated input, useful progress, verification and recoverable application of the approved changes. Ease and speed are acceptance criteria, not reasons to skip compatibility or security checks.
The existing cargo rullst upgrade already provides workspace-aware plans,
versioned migration rules, controlled file snapshots, compiler fixes and Cargo
checks. It does not install the CLI or run the application’s full acceptance
suite. Extend this boundary in cargo-rullst; do not introduce another crate
or count this proposal as completed work in the capability ledger.
Implementation and acceptance order:
13.0.0-alpha.4 removes the CLI self-updater (update check, update verify, update stage, update install and the CLI half of update guided):
the CLI is installed and updated with cargo install cargo-rullst --version =X --locked, and a once-a-day notice prints that command. The project
preparation commands below stay. The paragraphs that follow record the 12.1.0
to 13.0.0-alpha.3 history.
Published 12.1.0 discovery uses interactive-only, offline/CI-aware notices and
bounded HTTPS metadata. It rejects redirects, yanked versions, prereleases and
unsolicited major jumps.
The legacy shared temporary cache is removed; the current result is deliberately
process-local. Explicit cargo rullst update check now provides exact-target,
MSRV/platform and versioned JSON discovery with separate major/prerelease
opt-ins; it grants no installation or execution authority. Explicit discovery
now reuses bounded, owner/permission-checked Unix metadata for six hours and
supports offline reads, forced refresh and cache opt-out. Windows persistence
has a private owner/DACL implementation with native acceptance recorded in the
maintenance checkpoint.
The 12.1.0 publication record
records final acceptance. Earlier v12.0.0 provenance exercises remain historical
evidence for their own artifacts.
Published preparation builds native CLI candidates on four explicit
targets and binds their version, source, platform, sizes and digests in a
bounded inventory. The admitted tag workflow separately attests the files and
adds release assets; ordinary CI inventories have no release tag. The admitted
12.1.0 release completed this native and publication evidence. Explicit update stage rechecks a fresh
registry selection, authenticates the manifest before executable downloads and
bounds HTTPS redirects, sizes, time and hashes in private storage. It executes
and installs nothing. Managed installation now revalidates eligibility, original
manifest provenance, private root ownership and exact bytes under a destination-local
lock. Explicit digest-approved application stages bounded version probes and
backups before replacing entries; explicit recovery restores only the authenticated
predecessor and rejects unrelated edits. Native installation/lock/interruption/
executing-image tests passed on Linux, Windows and macOS at ef0a8577 in
run 35296176849.
The explicit local update verify command now authenticates a private manifest
snapshot with the caller-installed GitHub CLI and checks both native binary
digests. It grants no installation authority or registry eligibility and does
not download, execute or install candidates. Its declared platform/release
acceptance is recorded with the published 12.1.0 flow below.
Working-source update project prepare now retains Git working contents and
the root lockfile in private before/candidate copies, reuses exact dependency
editing and the versioned source catalog, and rejects ambiguous inputs and
downgrades. Local process fixtures cover dirty/untracked/deleted source,
virtual workspaces, ignored or absent lockfiles, bounded findings and unsafe
files without compiling application code. Native preparation tests passed on
Windows and macOS at 88ddabad. Preparation grants no execution or application
authority. Working-source verification now revalidates records/source under an
operation lock, previews commands, and requires explicit trusted-code consent
before resolving/checking/testing another private copy. Local process tests
cover real acceptance, stale inputs, missing consent, contention, timeout,
failed tests and unexpected source writes. Verification passed Linux/macOS and
the corrected Windows path contract at the recorded maintenance checkpoints;
final release acceptance followed with the published 12.1.0 release. Explicit
update project review now revalidates command logs and both source inventories
and emits the bounded full dependency diff plus a review digest. It performs no
builds or original-file edits; the digest does not grant application authority.
Explicit digest-approved project apply/recover now revalidate evidence/access
policies, stage reviewed manifest/lockfile replacements and persist an intent.
They refuse divergent edits and preserve unrelated files. Source locks coordinate
only the same configured cache. Native project acceptance passed on all three
systems at 8b58cd12; corrected Windows SDDL/Git path handling is included.
Local child-kill, OS file-size-limit and injected replacement/recovery faults
add bounded interruption proof, not power-loss or a complete disk-full matrix.
Working-source update guided --to VERSION composes CLI/project/both flows
through the same validators. It retains exact paths and separate review digests,
shows complete reviews, defaults each approval to no and reports elapsed time
per stage. Project code/network and original-file writes need separate consent.
Noninteractive approval and unsupported project major jumps reject. A real
terminal regression covers declined preparation/execution, failed application
tests and approved application followed by exact recovery. The published
12.1.0 release record holds the final release evidence for this composition.
- Discover and explain. Make update notices useful without blocking normal CLI startup. Respect offline/CI settings and explicit notification opt-out; use bounded responses, timeouts and a private, path-safe cache. Default to supported stable releases within the selected major, with exact target pins; prereleases and major migrations require explicit selection. Show release notes, MSRV/platform requirements and unsupported migration paths before proposing changes. Metadata or a notification never grants installation authority.
- Update the CLI safely. Offer verified prebuilt binaries for supported OS/architecture pairs, with a pinned source-install fallback when appropriate. Bind artifact identity/version/platform/digest to a trusted publisher identity through signatures or verified provenance; a checksum from the same untrusted download is insufficient. Respect package-manager ownership and permissions; stage replacements with concurrency locks, interrupted-download recovery and Windows executable-lock handling. Test rejected tampering, unexpected redirects/archives, stale metadata and unauthorized downgrades. Keep an explicit, verified known-good recovery path, not a silent downgrade.
- Prepare and verify the project. Reuse the versioned migration catalog in an isolated working copy, preserving uncommitted user work. Show dependency, lockfile and source diffs and the validation commands before execution; Cargo build scripts, procedural macros and tests execute code, so preparation is not a sandbox or permission to run an untrusted project. Resolve the chosen target reproducibly and validate the candidate lockfile, supported feature sets and application-owned tests before accepting changes. Unknown or breaking migrations stop with actionable instructions instead of guessed rewrites.
- Apply with consent and recover. Make the interactive happy path concise; expose structured reports and explicit non-interactive policy for automation. Verify that reviewed inputs have not changed before applying. Preserve bounded backups and prove cancellation, concurrent edits, disk-full recovery and restoration on Linux, Windows and macOS. State exactly which files are restored: file rollback does not undo arbitrary test side effects, database changes or external services. Opening an application or running a build must never silently replace its framework, migrate its database or deploy it.
- Prove usability and speed. Exercise published-package/generated-project fixtures, pinned versions, offline operation, unsupported targets and failed migrations. Measure cold/warm discovery, download, compilation and validation separately. Reuse only correctly keyed caches and applicable test evidence. A prebuilt CLI can avoid CLI compilation; updating a Rust application can still require rebuilding, testing and a separate deployment. Do not promise instant upgrades, zero downtime or automatic production readiness.
The 12.1.0 delivery must preserve v12’s public APIs, CLI/configuration behavior
and opt-in boundaries. It prepares discovery and installation of a compatible
migration CLI, not guesses about a future major’s source changes. Actual
v12-to-v13 automation requires v13’s published migration catalog and tested
application fixtures; the same-major restriction of the v12 upgrade command
must not be silently removed. Reserve incompatible changes for v13.
On main, the v13 CLI accepts Rullst 12 and 13 projects: cargo rullst upgrade
reports v12 → v13 source findings with their
migration-guide rows. These support a review;
they do not migrate an application automatically.
Continue new capability work on v13; the v12 line is no longer maintained after its end of life on 16 October 2026. The website redesign is a separate documentation delivery, not a reason to bump framework versions or postpone verification work. This plan neither bumps package versions nor authorizes publication, and it does not claim that any major application migration is already automatic.
API documentation quality
Status: planned for v13; improve documentation alongside each implemented contract, not only at release time. Distinguish the Rust framework API (types, traits, functions and features) from guides for building HTTP/REST APIs. The existing REST quickstart intentionally covers one JSON endpoint; routing, authentication and Scalar/OpenAPI guidance live in separate chapters. More pages alone will not make those paths easier to discover or complete. The v12 navigation index now connects existing guides to exact-version crate references and identifies the remaining REST walkthrough gaps. This first navigation improvement is not completion of the reference and behavioral-example programme below.
Use Qt’s reference navigation and a concrete class reference as organizational inspiration, not as a reason to adopt Qt or copy its text. Adapt the pattern to Rust with a searchable crate/module/task index linking the book and version-pinned rustdoc pages. For prioritized public interfaces, document purpose, imports/Cargo features, arguments, results and typed errors, security/ownership/concurrency constraints, runnable examples, related APIs, version availability and migration notes. Start with the umbrella facade, Core/routing, Auth/Security and ORM; extend coverage with each v13 increment.
Provide a coherent REST learning path covering typed input validation, error responses, CRUD and pagination, authentication, owner/tenant authorization, OpenAPI, tests and deployment boundaries. Validate examples against the documented release and feature set, including rejected input and access denial. Keep the existing book-doctest integration and add behavioral fixtures where compilation alone cannot prove the documented result. Check rendered book links as well as repository-local links. Record which API surfaces were reviewed; do not infer complete reference coverage from a green book build or label unimplemented v13 contracts as available in v12.
Ideas recorded for after 13.0
Recorded on 8 October 2026. These are ideas, not commitments or shipped features, and they add no milestone to the counts above.
- Offline IoT/health example application. Sensor readings flow to a Rullst
server, are stored in SQLite and appear on a dashboard, with no internet
connection required. It would build on
SensorTelemetry,AnomalyDetectorand the snapshot card from the experimentalrullst-iot, with a maintained transport crate (for examplerumqttc) rather than a new Rullst transport. A health-oriented variant must treat readings as sensitive personal data and must not present itself as a medical device. - Multiplayer quiz example. Core realtime and presence (
BroadcastManager,PresenceTracker) for live rounds, withrullst-labsgrading contracts for server-authoritative, idempotent scoring. Answers are graded on the server, never trusted from the client. - Reverse-proxy configuration instead of a gateway. The self-hosted
rullst-gateway(M39) is abandoned. Deployment work goes into generated, reviewable configuration for established proxies: extend the existing Caddy output and add nginx, with the readiness/drain and trusted-proxy contracts thatrullst-corealready exposes.
Delivered in 13.0.0-alpha.3
Planned on 15 and 16 October 2026 with the maintainer and prepared on 23 October 2026 as the 13.0.0-alpha.3 release candidate; the coordinator records publication in the table below. Each item states what the release contains and what stays open. They add no milestone to the counts above.
-
[x]First-class PWA, and retiring Omni (done in the 13.0.0-alpha.3 source; see the PWA guide). Decided on 16 October 2026 with the maintainer. Themake:omniTauri shells (desktop, Android, iOS) only open the hosted Rullst application in a native webview and deliberately expose no native API to it, so they give the same result as packaging a Progressive Web App, while costing three mandatory release workflows (Android NDK, iOS simulator, desktop on three systems), Tauri upgrades and per-platform signing. alpha.3 replaces them:cargo rullst make:pwa: web app manifest, icons, a service worker with an offline page and an update prompt, compatible with Core’s CSP nonces andCache-Controldefaults; browser-side offline data (IndexedDB) where an application needs it.- Google Play through a Trusted Web Activity: a Bubblewrap configuration
(
twa-manifest.json) and a/.well-known/assetlinks.jsonroute fed by the application’s signing-key fingerprint. Bubblewrap is the scriptable tool PWABuilder itself uses for Android. - Microsoft Store: a guide to packaging the same PWA as MSIX with PWABuilder, which Microsoft supports for its store. The Apple App Store usually rejects apps that only wrap a website (guideline 4.2), so iOS users install the PWA from Safari.
- Remove
make:omni,cargo rullst omni, the three Omni release workflows and the native-onlyoffline-syncfeature, whose only consumer is the Omni shell, before 13.0.0 (the last release where removing them does not wait for v14).
All four are implemented:
make:pwa(with--androidfor the Bubblewrap configuration),rullst::pwa::AssetLinks, the Microsoft Store and iOS sections of the guide, and the removal, withcargo rullst upgradefindings for an Omni shell and theoffline-syncfeature. They ship with 13.0.0-alpha.3; store acceptance and physical devices stay application work. -
[x]Studio as a production monitor for several projects (phases 1 to 3 done in the 13.0.0-alpha.3 source; see the production monitoring guide). Before alpha.3 Studio is a development tool: it runs inside the application process in debug builds, listens only on127.0.0.1:5555and reads that process’s own telemetry, queues, cache and local database. The only production-facing piece is the signed, push-only trace ingestion of M35. The plan keeps every development page and adds a remote mode in three phases:- A read-only production monitoring endpoint in the application, behind the
application’s own login policy (the policy the Nexus package for alpha.2
adds) or a dedicated token. It reports request rate, latency, errors,
queue depth and failures, and N+1 findings, never database rows,
.envvalues or configuration secrets. - A standalone Studio “remote projects” mode on the developer’s machine: a project list with a switcher, per-project credentials encrypted locally, and the monitoring pages (radar, traces, jobs, security radar) reading the selected project’s endpoint. Studio stays bound to loopback; it only connects outwards. Reaching private networks is left to established tools (WireGuard, Tailscale, an SSH tunnel), which the guide documents instead of Studio implementing VPN access.
- History and alerts.
All three phases are implemented for 13.0.0-alpha.3: the
/_rullst/monitor/v1endpoint (Server::with_monitor), the standalonerullst-studiobinary with overview, routes, queues and N+1 pages, and its bounded local history with per-project alerts and an optional webhook. Traces and the security radar are not part of the phase 1 document, so remote mode does not show them yet; desktop notifications are not implemented (alerts show in Studio and can go to a webhook). The data browser,.envviewer, AI and API playgrounds and migration pages stay local-only, because in production they would expose secrets and personal data or run arbitrary operations. The work needs a threat-model update before phase 1 ships. Estimated at three to four focused packages plus mutation tests.
- A read-only production monitoring endpoint in the application, behind the
application’s own login policy (the policy the Nexus package for alpha.2
adds) or a dedicated token. It reports request rate, latency, errors,
queue depth and failures, and N+1 findings, never database rows,
-
[x]The rest of the mutation campaign (all butcargo-rullstdone in the 13.0.0-alpha.3 source;cargo-rullstmoves to the next section). As the verification order requires:rullst-ormandrullst-orm-macros: re-measured in full for alpha.3 on hosted runs, with all-feature targeted runs and theorm-sqlite,orm-polyglot,orm-sql-servers,orm-strict-postgres,orm-strict-mysqlandorm-polyglot-serverscontracts ofmutants.yml, which run the SQLite, optional-backend and database-server suites that the all-feature build compiles out.rullst-ormhas 2,414 mutants: 1,967 caught, 421 unviable, 17 timeouts and 9 missed, of which 8 are accepted equivalents. The ninth is the MySQLFOR UPDATE SKIP LOCKEDclaim query ofOutbox::claim_next_at_inner, which only concurrent claims tell apart from the portable query.rullst-orm-macroshas 542 mutants: 514 caught, 26 unviable and 2 accepted equivalents. A full all-feature campaign still reports the mutants that only those contracts reach.rullst-core: the files the first pass left (PR #465) were finished in PR #532: 78 files and 2,435 mutants, 72 missed at the end (71 documented equivalents; the remaining one needs an open PostgreSQL pool).rullst-nexus: measured for the first time in PR #532 (26 files, 876 mutants); 170 of 171 first misses were killed by new tests, one is a documented equivalent, and one real defect was fixed.cargo-rullstis not measured yet; a targeted campaign was stopped before completion and is not part of alpha.3.- The 29
rullst-capitalmutants that only a live Stripe response reaches stay with provider sandbox testing.
-
[x]Feedback from the platforms built on alpha.2. The reports from the platforms built on 13.0.0-alpha.2 chose the fixes in this release (see the changelog), as the alpha.1 report did for alpha.2.
Delivered in 13.0.0-alpha.4
Planned with the maintainer on 29 October 2026 and prepared on 29 and 30 October as the 13.0.0-alpha.4 release candidate, for a tag on 31 October; the coordinator records the publication in the table below after the tag. Goal: a lean CLI for people, a predictable framework for AI assistants, and the 13.0 feature and public API freeze. The items add no milestone to the counts above.
[x]Lean main CLI (PR #555). Foundry,nixify/new --nix,generate:buildah/new --buildah,make:grpc, the Fly.io, Render and Railway deploy targets and the self-updater’s download and binary replacement were removed; a once-a-day notice prints thecargo installcommand for a newer CLI. Each removal has a migration row and, where an application can be detected using it, an upgrade review rule.[x]greenin the main CLI, the terminal assistant removed (PR #563):cargo rullst green(Experimental; measured size, memory, dependency and CPU figures, carbon only as a labelled SCI estimate) replacesfootprintincargo-rullst.cargo rullst aiwas removed from 13.0 (its code stays in git history) and now prints what replaces it; therullst-aicrate is unchanged. A separatecargo-rullst-extraspackage was prepared in PR #555 and dropped before publication, so the release still has twenty packages. The development error page’s assistant panel became a read-only prompt for the developer’s own assistant (PR #572).[x]Deployment guides (PR #555): onedeploypath (Dockerfile,compose.yamlandCaddyfilefor Docker or Podman), the Linux VM, Hetzner Cloud, DigitalOcean, Oracle Cloud and Google Cloud guides and Advanced deployment (Nix, Buildah, Podman Quadlet, Kubernetes; described, not generated). The guides were written from the providers’ documentation and not followed on real accounts. CI builds and starts the generated stack for the Blank and SaaS starters with Docker and rootless Podman (PR #578), which found and fixed a SQLite crash loop under the read-only root filesystem.[x]Three starters, the others as examples (PR #558):cargo rullst newoffers Blank, API and SaaS; LMS, ERP and Portfolio are workspace applications underexamples/with their own tests.[x]For AI assistants (PR #555): one--jsoncontract with aschemaid (new forinspect models,inspect schemaandpkg list), andllms.txtwith an optional section, checked byvalidate-site.py.[x]Shorter development builds (PR #559): generated projects get a[profile.dev](line tables for the application, no debug information for dependencies), and the SaaS starter no longer declaresrullst-connect. On the measuring machine cold builds took 15 to 19 % less time, rebuilds after an edit about half andtarget/about 41 % less disk (Build times).[x]FromRowin#[derive(Orm)], no SQLx macros, Nexus AI opt-in (PR #582): the derive implementsFromRow(#[orm(custom_from_row)]keeps an application’s own) and no longer needstracingortokioin the application; no Rullst crate enables SQLx’s macros; Nexus’s AI assistant is the facade’snexus-aifeature. Generated starters compile 62 fewer units, and cold builds took 15 % (Blank) and 10 % (SaaS) less time on the measuring machine.[x]One TLS crypto provider (PR #580): aws-lc-rs is the rustls provider for every TLS connection; ring remains a primitives library.cargo rullst upgradereviews applicationsqlxdependencies that keep ring (V13-SQLX-TLS-RING, PR #589).[x]Public API freeze (PR #588): accidental internals are#[doc(hidden)], each library crate’s documented API is recorded in.github/public-api/and checked by a SemVer-workflow job, and the compatibility policy defines public dependencies and the 13.x support window.[x]Documentation audit and first-hour fixes: the guides, tutorials, references, plans, crate pages, docs.rs pages, README and landing page were checked against the code (PRs #562, #564–#566, #568, #569, #571 and #573–#577; docs.rs feature sets in #584). The CLI defects it found were fixed:cargo rullst authon fresh starters,make:migration:auto‘s exit status,new --no-databasefiles (PR #586), install hints and generator messages (PRs #567 and #570). A CI job now runs the first-hour pages’ commands as written.[x]CI (PRs #581, #585 and #587): weekly Miri and Kani runs, afull-matrixpull-request label, ZAP on the generated SaaS starter, a SemVer baseline that starts comparing at 13.0.0, release fuzz evidence derived from each fuzz package’s build graph, and 34 workflows after the supply-chain workflows were merged andcorpus-sync.ymlremoved.[x]Targetedcargo-rullstmutation campaign (PR #557): hosted targeted runs with the CLI’s library tests measured 83 CLI files and 3,189 mutants of the self-update, audit and IDOR scanner,addand consumer-file editors,doctorand deployment configuration andupgradecode. New tests killed 1,041 of the 1,129 first-run survivors and 51 are documented equivalents; the 76 files left without a survivor were listed in.github/mutants-pr-cli.txt, somutants-pr.ymlmutates their changed lines on every pull request. Left: size and count bounds that only very large projects reach (hundreds of mebibytes, or 10,000 to 250,000 files): upgrade snapshot 6, upgrade source walk 3, project snapshot 13, replacement and verification totals 2 and 2, the 64 MiB preparation report 4. The Studio remote mode (20 files) was measured in the same campaign and has no survivor left. The self-updater’s binary replacement was removed in 13.0.0-alpha.4, with its 7 remaining installation mutants; its 13 measured files left the gate list with it.generators/deploy.rsandupdate/guided.rs, rewritten in that release, were measured again with thegreenfiles (next item).[x]green,deployand guided-update mutation measurement: hosted targeted runs with the CLI’s library tests measured the ninegreenfiles,generators/deploy.rsandupdate/guided.rs(337 mutants; 93 missed, no timeout). New library tests (a loopback test server, fake app and container engine scripts, child test processes for the commands that use the working directory or the terminal) killed 86; the other 7 are documented equivalents (anInstantdeadline compared with<=, and the clock tick rate). The 11 files are in.github/mutants-pr-cli.txt.
Planned after 13.0.0-alpha.4
Work items for later prereleases, not shipped features. They add no milestone to the counts above.
- 13.0.0-rc.1: fixes and documentation only. 13.0.0-alpha.4 froze the
features and the public API of 13.0. rc.1 adds no feature: it carries fixes
for defects found in alpha.4 (by its release admission, the first-hour and
deployment jobs and the platforms upgraded to it) and documentation
corrections. A change to a
.github/public-api/listing must be such a fix and is listed in the changelog and the migration guide. The maturity tiers state which packages the 13.x promise covers. After rc.1, changes are limited to defects found in the release candidate. - Facade docs.rs features: add
nexus-aito therullstfacade’s[package.metadata.docs.rs]list (and its public API listing), so its docs.rs page documents the Nexus assistant. - Turso/libSQL beyond the blank and API starters, only if users ask for it
and the maintenance cost stays low. Turso can be the primary database only
in those starters and does not work with
#[derive(Orm)],Schema,artisan!,Server::with_db, Nexus or Studio. A partial bridge (Schemaemitting Turso migrations,#[derive(Orm)]also generating the Turso model) could be a 13.x candidate on that condition. Full ORM parity is a decided non-goal (see “13.x minor candidates and decided non-goals”). - Feedback from the platforms upgraded to 13.0.0-alpha.3 and alpha.4. Their reports choose the fixes and priorities of the next prerelease, as the alpha.1 and alpha.2 reports did before.
- Then 13.0.0, once rc.1 leaves no open defect in the stability scope.
Published and planned release lines
| Version | Status | Honest scope |
|---|---|---|
| v12.0.0 | [x] Published stable | Tag v12.0.0 at eb11f892 completed the protected release workflow and published all sixteen packages on September 15, 2026. |
| v12.x | [x] End of life | Ended on 16 October 2026 with 12.3.0 as the last release; the v12 branch is frozen. New 12.x projects no longer resolve dependencies after crates.io yanked the zip releases its Studio needs; use v13. |
| v12.1.0 | [x] Published compatible minor | All sixteen packages were published from b62390b4 on 20 September 2026 UTC. Guided CLI/project updates, SaaS/Nexus fixes, account mail and the other bounded contracts are recorded with immutable source and registry evidence in the publication record. |
| v12.1.1 | [x] Published patch | All sixteen packages were published from d27db26c on 24 September 2026 UTC; see the publication record. |
| v12.1.2 | [x] Published patch | Tagged v12.1.2 on 28 September 2026 UTC from the v12 maintenance line; its publication evidence is kept in the frozen v12 release record. |
| v12.2.0 | [x] Published compatible minor | Tagged v12.2.0 at 565e222a on 3 October 2026 from the v12 maintenance line; see the frozen v12 release record. |
| v12.3.0 | [x] Latest published stable | Tagged v12.3.0 at 67b2d0e5 and published on 9 October 2026: hickory 0.26.3, fewer WAF/RASP false positives and deprecations of the Capital and Mail APIs that 13.0 removes. It is the last 12.x release: the v12 line reached end of life on 16 October 2026 and no further patch will be published. |
| v13.0.0-alpha.1 | [x] Published prerelease | Tag v13.0.0-alpha.1 at fc44bf76; all twenty packages were published on 11 October 2026 for evaluation. See the delivery plan. |
| v13.0.0-alpha.2 | [x] Published prerelease | All twenty packages were published on 17 October 2026 from tag v13.0.0-alpha.2 at 0daf4941 (release run 39041726912): the fixes from the alpha.1 dogfooding report and the breaking html! → Markup, Schema date/time and Nexus changes. A first tag at 669befe6 failed release verification before publishing anything and was replaced. |
| v13.0.0-alpha.3 | [x] Published prerelease | All twenty packages were published on 24 October 2026 from tag v13.0.0-alpha.3 at be336833 (release run 40129821475) after all 25 required workflows passed on that commit. See Delivered in 13.0.0-alpha.3. |
| v13.0.0-alpha.4 | [ ] Release candidate | Prepared on 29 and 30 October 2026 for a tag on 31 October and not yet published; the coordinator records the tag, release run and publication here. Twenty packages; the 13.0 feature and public API freeze. See Delivered in 13.0.0-alpha.4. |
| v13.0.0-rc.1 | [ ] Planned | Fixes and documentation only after the alpha.4 feature and API freeze. See Planned after 13.0.0-alpha.4. |
| v13.0.0 | [ ] Planned stable | Follows once rc.1 leaves no open defect in the stability scope; the maturity tiers apply from this release. |
| v13.x | [ ] After 13.0.0 | Compatible capabilities continue in 13.1 and later minors: generated-project coverage, auth/session consolidation, typed SDKs, selected adapters, security-stack consolidation and research-heavy architecture all require fresh acceptance boundaries. |
The framework may call a milestone implemented only when the same commit passes the repository’s formatting, strict lint, full-test, feature-matrix, security, and packaging gates. Performance numbers must cite a reproducible benchmark; security and compliance claims must state their threat model and evidence scope.
13.x minor candidates and decided non-goals
Recorded on 30 October 2026 with the maintainer. Minor releases may add these without breaking the 13.x API (support window); none is scheduled, and real user demand orders them.
- Production monitor (13.1 candidate): desktop notifications in the standalone Studio, remote traces and security radar, and proxy support.
greenon macOS and Windows: measure idle memory and CPU time there too (for example throughsysinfo), not only through Linux/proc.OtaManagergetters (Experimental tier): private fields with getters of the same names, as announced in 13.0.0-alpha.4.- Unquoted text in
html!: an ergonomic grammar addition, only if users ask for it; quoted literals keep working. - More i18n locales and currency formats, added as CLDR data when requested.
Decided non-goals (not planned, so they are documented as scope rather than as pending work):
- Full ORM parity for Turso/libSQL. It would need a second database client
path beside SQLx for every ORM feature (
#[derive(Orm)],Schema, migrations, Studio and Nexus pages), doubling the maintenance and test matrix. Turso stays a bounded primary database for the Blank and API starters with its own client. - Reading the CSRF
_tokenfrom any position of a multipart body. It would mean buffering uploads before the CSRF check. Generated forms already send_tokenfirst, and clients can send theX-CSRF-Tokenheader.
v14 candidates
Breaking ideas raised during 13.x are collected here instead of shipping in a 13.x release (support window). None is scheduled; v14 development starts only when they justify it.
- Make the internals hidden from the documentation in 13.0.0-alpha.4
(
#[doc(hidden)]) crate-private. - Replace third-party types in public error variants and
Fromconversions (reqwest,jsonwebtoken,base64,redis,serde_json) with Rullst error types, so fewer crates are public dependencies. - Make the
dashmapfields ofrullst_security::SecurityStore(banned_ips,honeypot_route_hits) private, behind methods. - Keep one path per item: remove
rullst::rullst_orm(same asrullst::orm),rullst::security_runtime(same asrullst::security::runtime) and Mail’s alias modulesrullst_mail::mailandrullst_mail::drivers::mock(same as the crate root andrullst_mail::mock).
"All glory and honor to God יהוה in the name of Yeshua the Messiah (Jesus Christ)."
Rullst Blueprints Roadmap 🗺️
“A practical collection of reviewed Rullst application starters”
This document maps the expansion plan for the Rullst Starter Blueprints ecosystem. The goal is to provide reviewable application starting points with explicit production checklists and capability boundaries.
🚀 Blueprints Design Philosophy
Every blueprint added to the CLI must meet three fundamental principles:
- Clear first experience: Responsive, accessible interfaces whose browser assets and CSP are explicit.
- Native Rust/Rullst Features: Demonstrate measured resource use, typed concurrency and explicit server/realtime boundaries.
- Production-minded defaults: Generate
.env.example, database configuration, and a conservative.gitignore; deployment readiness remains an application-level review.
The v13 privacy and age-assurance programme
applies across blueprint shapes: minimize collection, disable optional tracking,
generate explicit policies and rights workflows, and require age evidence only
when the selected risk policy calls for it. Camera checks are not a universal
default. The SaaS maintenance triage separately covers
the v12.1/v13 payment and generator defects reported by Rullst/examples.
🗺️ Proposed blueprints (ordered from easiest to hardest)
These rows describe design targets and are not selectable CLI starters; ERP
exists as the examples/erp application. A proposal becomes implemented only
when its generated project and negative boundaries pass the release gates.
Since 13.0.0-alpha.4 cargo rullst new --blueprint offers blank, api and
saas; the former LMS, Blog, Portfolio and ERP starters are complete
applications under the repository’s examples/ (see examples),
and a new domain is more likely to arrive as an example than as a starter.
| ID | Blueprint Name | Technical Focus in Rullst | Commercial Differentiator |
|---|---|---|---|
| 4 | 💼 ERP Pocket (Inventory) | Embedded SQLite + rullst::nexus (Auto-CMS) + Single Binary | The examples/erp application (a starter until 13.0.0-alpha.4); crash recovery and backup remain application work. |
| 5 | 📋 Member/Club Management | Validation + Nexus + reviewed receipt adapter | Proposed member and billing domain starter. |
| 7 | 🤖 AI Agent & RAG Boilerplate | rullst-ai + opt-in document parsing/embedding adapters | Proposed RAG starter; uploaded content remains untrusted. |
| 8 | 🪙 AI Credit-Based SaaS | SSE + transactional usage ledger + payment adapter | Proposed AI SaaS starter with server-owned credit reservation. |
| 9 | 🏥 Scheduling & Clinics | HTMX calendar + scheduler + database conflict policy | Proposed scheduling starter with database-specific contention tests. |
| 10 | 🚪 Biometric Access Control | WebSocket foundation + real-time concierge panel | Planned access-control starter; device trust and latency need deployment-specific validation. |
| 11 | 📈 Affiliate Checkout | SSR + commission-split domain model + landing page | Planned sales starter; performance and Lighthouse scores must be measured per application. |
| 12 | 🏢 B2B Multi-Tenant Platform | Tenant context + RBAC + rullst-mail | Planned B2B starter; isolation must be proven across every storage and messaging boundary. |
| 13 | 💬 Discord-Like Realtime Chat | Server-driven UI + authenticated WebSockets | Proposed chat starter; distributed presence and load evidence are required. |
| 14 | 🛵 Delivery / Food App | Background queue + explicit order state machine | Proposed delivery starter with idempotent jobs and notification adapters. |
🔍 Highlighted Architectural Details
🪙 8. AI Credit-Based SaaS (The Token-Burner)
- Architecture goal: a server-owned chat flow with bounded SSE streaming, cancellation and provider error handling.
- Data Security goal: use a database transaction and provider-specific lock semantics to reserve credit before an LLM request. This workflow is not yet a generated, cross-database guarantee.
- Monetization goal: integrate a reviewed usage ledger with a supported payment adapter; billing portals remain provider/application work.
🏢 12. B2B Multi-Tenant Platform (The Corporate Boilerplate)
- Isolation goal: derive a validated
TenantContextat the HTTP boundary and carry it explicitly through database, cache, queue and realtime operations. Rullst does not inject a tenant predicate into every arbitrary SQL statement. - Permissions goal: typed roles (
Admin,Member,Billing) enforced in middleware and again at sensitive service boundaries. - Invitations goal: hashed, single-use, expiring invitation tokens delivered through a configured mail adapter.
💬 13. Discord-Like Realtime Chat
- Client goal: use server-rendered messages and an explicit WebSocket client without making bundle size a proxy for correctness.
- Scale gate: publish connection count, message mix, backpressure behavior, CPU/RSS, hardware and distributed topology before attaching capacity numbers.
🏥 9. Scheduling & Clinics (The Scheduler)
- Conflict-prevention goal: enforce a database constraint plus a transaction strategy tested on every declared backend; isolation level alone is not a universal double-booking proof.
- Reminder goal: use bounded, idempotent scheduler jobs and a configured mail adapter. Multi-instance leadership and durable retry must be explicit.
🤖 7. AI Agent & RAG Boilerplate (AI-Native)
- Structure goal: quarantine and scan bounded uploads, parse supported formats in an isolated adapter, generate embeddings and store an authorized index.
- Provider goal: expose an explicit provider selection and capability check. Changing an environment variable does not make data policy, schema or model behavior interchangeable.
Community dependency helper and package roadmap
Rullst (v12 and the v13 prerelease line) does not define a runtime
RullstPackage plugin ABI, execute third-party generators, discover a private
package registry, or register routes automatically. Community integrations are
ordinary Rust crates selected through Cargo and reviewed like any other
dependency.
Current cargo rullst pkg boundary
The CLI provides two small local manifest helpers:
cargo rullst pkg add rullst-auth
cargo rullst pkg list
cargo check
pkg add:
- accepts only ASCII Cargo names of at most 64 bytes beginning with
rullst-orrullst_and ending in an alphanumeric character; - parses
Cargo.tomlas TOML and inserts the dependency into the real[dependencies]table using the installed CLI’s version (an exact=requirement for a prerelease such as=13.0.0-alpha.4); in a virtual workspace manifest ([workspace]without[package]), which cannot declare[dependencies], it uses[workspace.dependencies]instead, and members opt in withrullst-auth = { workspace = true }; - leaves an existing dependency and its features/version unchanged;
- does not contact a registry, execute code, edit routes, or run a scaffold.
The subsequent cargo check performs normal Cargo resolution and compilation.
A prefix is only a naming filter; it is not proof that a crate is official,
safe, compatible, maintained, or endorsed by Rullst. Review the crate source,
publisher, checksum, license, advisories, feature graph and release policy
before adding it.
pkg list reads dependency keys from that same table and prints those with
the same Rullst prefix (--json prints them as versioned rullst.cli-pkg.v1
JSON). It is not a vulnerability, license or provenance scan;
use the repository’s audit and dependency-policy tooling for those questions.
Integrating a community crate today
A community crate can expose ordinary Axum routers, Tower layers, SQLx types or constructors. The host application initializes it explicitly and mounts only the capabilities it intends to trust:
#![allow(unused)]
fn main() {
use axum::Router;
fn community_router() -> Router { Router::new() }
fn application_router() -> Router {
Router::new().nest("/community", community_router())
}
}
Authentication, authorization, tenant policy, secrets, migrations, shutdown, telemetry and failure handling remain visible application responsibilities.
Future package protocol
A first-class extension protocol remains roadmap work; the v13 source does not include one. It should not be declared stable until the repository has all of the following:
- a versioned manifest and compatibility contract;
- explicit capability permissions for routes, storage, network and secrets;
- deterministic lifecycle and failure semantics without runtime reflection;
- package ownership, provenance and revocation policy;
- compile/runtime conformance tests and safe uninstall/upgrade behavior;
- a decision on whether untrusted extensions require a real Wasm sandbox with enforceable CPU, memory and I/O limits.
Until that contract exists, documentation and packages must not claim that
RullstPackage.toml, RullstPackage, automatic route registration, or
third-party generator execution are implemented APIs. See the
capability ledger
and the roadmap for the preserved vision.
v13 delivery plan and evidence
13.0.0-alpha.4 prepared on October 29 and 30, 2026, for a tag on October 31
(not yet published). The release commit sets the twenty packages of the
release order to 13.0.0-alpha.4; the coordinator records the tag, the
exact-SHA admission and the release run here after publication. It is the
13.0 feature and public API freeze. It makes the main CLI leaner: green
(formerly footprint) stays in it as an Experimental command and the terminal
AI assistant (cargo rullst ai) is removed, while the development error page
offers a read-only prompt for the developer’s own assistant. It removes
Foundry, Nix, Buildah, gRPC, the Fly.io, Render and Railway deploy targets and
the self-updater’s binary replacement; gives deploy one path for any Linux
VM, which CI builds and starts with Docker and rootless Podman, with guides for
Hetzner Cloud, DigitalOcean, Oracle Cloud and Google Cloud; keeps three
starters and moves LMS, ERP and Portfolio to the repository’s examples beside
Blog; makes cargo rullst auth work on fresh starters; adds one --json
contract and an optional section to llms.txt; and shortens the first builds
of generated projects with a development profile and without SQLx’s macros.
In the framework crates, #[derive(Orm)] implements FromRow, aws-lc-rs is
the single rustls crypto provider, the Nexus AI assistant is the opt-in
nexus-ai feature, and internals documented by accident are hidden; each
crate’s documented API is recorded in .github/public-api/ and checked in CI.
A documentation audit checked the book, the crate pages and the README against
the code, and a CI job runs the first-hour pages’ commands as written. The
first targeted cargo-rullst mutation campaign is merged, and the CLI’s
measured files are in the pull-request mutation gate. Next come 13.0.0-rc.1
(fixes and documentation only) and then 13.0.0. Work planned next is under
Planned after 13.0.0-alpha.4.
13.0.0-alpha.3 published on October 24, 2026. All twenty packages were
published from tag v13.0.0-alpha.3 at be336833 after the 25 required
workflows passed on that exact commit
(release run 40129821475).
It carries the fixes from the alpha.2 dogfooding reports,
first-class PWA support and the retirement of Omni, the Studio production
monitor (endpoint, standalone remote mode, history and alerts), OAuth login
with make:oauth for Google, GitHub, Microsoft and Apple, CLDR plurals and
formatting, and the breaking changes listed in the changelog. The mutation
campaign finished rullst-orm, rullst-orm-macros, rullst-core and
rullst-nexus; cargo-rullst is not yet mutation-measured and its targeted
campaign continues after this release. The work planned next was delivered in
13.0.0-alpha.4.
13.0.0-alpha.2 published on October 17, 2026. Tag v13.0.0-alpha.2 points
at 0daf4941. Release run 39041726912
followed an exact-SHA admission of all 28 required workflows; the release fuzz
campaign (42 targets, no crash) ran on 01aa4b10 and was reused for the
release commit, whose fuzz inputs were identical. It carries the fixes from the
alpha.1 dogfooding report and the breaking html! → Markup, Schema
date/time and Nexus changes (see the changelog). Admission found and fixed, before
publication: the upstream zip yank (Studio’s Swagger UI page replaced), Windows,
sanitizer, coverage and Miri failures, an exact-pin packaging check, and the
zip 6 advisories (a governed exception until 2026-10-31). A first tag at
669befe6 failed release verification before publishing anything (the CLI
evidence audit did not receive the exception) and was replaced after the
maintainer’s approval. The work planned next was delivered in
13.0.0-alpha.3.
13.0.0-alpha.1 published on October 11, 2026. Tag v13.0.0-alpha.1 points
at fc44bf76. Release run 38151068563
followed an exact-SHA admission of all 28 required workflows. Its fuzz evidence
was reused from campaign 38056935372. All twenty crates on crates.io match the
release checksums. The GitHub release is a prerelease, and 12.3.0 remains the
latest stable release.
A smoke test used the published Linux CLI to generate a Blank SQLite project
from the registry crates. The project built, passed its tests and served /.
First publication of the four new names is recorded in the
release recovery runbook.
Direction revised on September 22: maintain stable v12.1 and continue v13 according to product value, evidence and available resources. The owner no longer requires publication by September 26; it is a review/handoff point, and work may continue in later sessions. The earlier calendar below is retained as history, not a release obligation. The priority proposal classifies all 41 master milestones without requiring the entire roadmap for 13.0.0. The SST governs APIs and architecture; no deadline or alpha label waives a security or publication gate.
Maintenance direction adopted on September 22: follow the maintenance scope and extension strategy for future v13 work. Preserve common application foundations, contain Capital’s scope and assess independent lifecycles for specialized extensions. This planning decision does not change current package admission, publication inventory or v12.1.1. Earlier implementation queues below are historical where they imply broader expansion without the strategy’s product, maintenance and acceptance criteria.
Evaluation direction adopted on September 23: after the active v12.1.1 maintenance admission, begin the bounded comparison plan with a usable application journey. The 25 A themes are investment priorities, not a requirement to finish every theme before measuring effort, failures, performance and maintenance. Use findings to select later work; this adds no release gate, paid resource, live-account test or claim of superiority.
The owner asked about an alpha-first release on September 22. The
13.0.0-alpha.N prerelease versions support that evaluation path: consumers opt in
explicitly and APIs may change before stable v13. Alpha and release-candidate
labels do not waive the applicable verification, ownership or
protected-publication checks. Choosing a prerelease label does not authorize an upload; stable
13.0.0 still requires its own readiness decision.
Direction recorded on 2 October: publish 12.2.0 from v12 first, through
its complete admission and the protected crates.io step. The v13 publication
decision is deferred to the owner’s next working session, after 7 October. The
owner is considering a first v13 alpha that includes the current candidate
packages as well as the sixteen published ones. That option is not yet
approved, and the alpha label does not waive the checks above: each candidate
still needs its own admission and a place in the release order before upload.
main remains the v13 development line. When stable 13.0.0 is published, a
v13 maintenance branch can be cut from its release source, as v12 was, and
main moves on to the next major.
Verification order adopted on 3 October: the 12.2.0 admission showed that mutation findings arrive too late once the release gates have started, because any correction then forces a new exact-source admission. For v13 releases, mutation testing moves ahead of the version freeze. It stays manual and informational:
- During development, a pull request that changes security-critical code gets
a manual targeted
mutants.ymlrun for each changed production file before merge. Critical code covers secret masking and redaction, authentication and sessions, rate limiting, request, mail and upload inspection, webhook and token verification, and access control. Meaningful survivors get tests in the same pull request. - Before the version freeze, a full campaign runs on the candidate source. Each survivor is triaged as a defect to fix, a missing test in critical code to add, or an equivalent or low-value mutant accepted with its reason. Critical areas aim for at least 90% caught under the conservative count; other areas must not fall below the previous release.
- Only then does the version change land, followed by one run of the exact-source release gates, with fuzzing dispatched first because it takes longest.
Mutation testing stays out of the required pull request checks. Each mutant rebuilds and retests its crate, so a run takes from tens of minutes to hours, and it would compete with the release gates for the same hosted runners. A non-blocking run triggered by a pull request label can be reconsidered if runner capacity grows.
Source-line transition approved on 21 September
The owner approved development on main, stable maintenance on v12, and
retention of the Pages branch. Stable source 184bc1f7 is preserved in the
v12 history. PR #237 prepared its
maintenance workflows and versioned release rules; it merged at 0bb7406b
after all 43 required checks and 24 relevant workflows passed. main and v12
then had the same strict, admin-enforced 43-check protected profile. (On
29 October 2026 main requires 19 status checks and no longer requires an
up-to-date branch; the end-of-life v12 keeps 46 strict checks. See the
governance policy.)
The six-feature source was admitted in PR #236 as recorded below.
PR #238 combines that source with
all six Dependabot updates and the 13 → main, 12 → v12 transition. Its exact
combined source passed workspace/platform, coverage, security and
extracted-package admission and merged normally at 0ce3306d. No tag or
published crate changed.
Approved delivery sequence
The release owner approved this scope on 20 September 2026 UTC: a first-party, provider-independent privacy foundation, optional external integrations, and a local facial engine as follow-up work. This does not make declarations into verified attributes or permit incomplete higher-assurance checks in production.
The privacy decision sets the first required product journey. The broader execution queue below was reconciled with the master roadmap on 20 September after the owner asked for more of the planned v13 capabilities. Privacy, migration and release preparation are the minimum priorities, not a ceiling on useful implementation during the active sessions.
The prioritized implementation sequence is:
- Retain the admitted v12.1 closeout and admit the v13 integration after its existing hosted checks pass; carry applicable stable corrections and keep mutation findings visible.
- Admit the optional PostgreSQL replay candidate after its hosted checks. Its real-database tests cover concurrency, quota, expiry, failures, cancellation, clock rollback and server restart. Keep SQLite’s shared-local boundary and PostgreSQL’s operator-owned replication/failover obligations explicit.
- Build an authenticated first-party declaration journey, with server-owned policy, tenant/session/action binding and rejection when stronger assurance is required. Exercise the supported SaaS/LMS consumer shapes without a paid provider, fake age determination or mandatory camera capture.
- Implement purpose/version choices, effective withdrawal of optional processing and one scoped rights workflow that actually exports or erases application data. Test ownership, cross-tenant denial, retention and restore boundaries.
- Extend the product queue with server-side SaaS plan entitlements, a typed API contract with its first TypeScript consumer, and CLI verification of signed Android artifacts. Their bounded acceptance requirements appear below. Resolve their SST/API decisions before implementation; none is shipped merely by appearing in this plan. Independent work can advance while hosted checks run, without launching duplicate campaigns for unchanged inputs.
- Inventory the actual 12.1-to-13 compatibility changes as each feature lands, implement the supported migration rules and exercise generated consumers, stale inputs and recovery.
- Improve the documentation and bounded generated project context for the delivered scope. Attempt the small production-linked Verus pilot only after the mandatory feature work is ready for its verification campaign.
- First additional priority, explicitly requested on 20 September: evaluate
rullst-supervisionfor transparent exam/learner supervision and parental rules within the application, ahead of other conditional extensions. Start after the required deliveries are implemented and validated, leaving time for combined acceptance and publication. The preliminary engineering estimate is one to two days for a bounded first journey and its tests, plus hosted CI; it is not a commitment to complete device-wide controls by 26 September. - Subsequent September 20 direction: finish usable journeys before opening more optional implementation streams. After the current deployment and supervision implementations, prioritize Bunny Stream private course video, then Labs with an actual isolated exercise/grading path. A signing helper or mock-only Labs contract is an intermediate checkpoint, not either product’s completion. Remote brokers, a new gateway and expansion of the Verus pilot follow these journeys; mandatory fixes and release checks retain precedence.
Additional priorities approved on 21 September
After the six-feature source admission, the owner selected the following two
deliveries for implementation through September 23. PR #238 passed source
admission and was merged into main at 0ce3306d; the v12 maintenance line is
preserved. Consent and email login joined the other five increments in PR #239,
which passed combined hosted and extracted-package source admission on September
22 and merged at 9f543ed5. Final release acceptance remains separate.
| Order | Selected delivery | Required acceptance |
|---|---|---|
| 1 | Shared PostgreSQL consent in rullst-privacy | Preserve purpose/version binding, exact-revision grants, withdrawal precedence, clock checks and durable tombstones across independent application pools. Add explicit initialization, restricted-role operation, bounded waits, concurrency/cancellation/restart tests, opt-in facade features and a documented consumer. Keep database restore/failover obligations explicit. |
| 2 | Single-use email login links in rullst-auth, composed with rullst-mail | Complete issuance, delivery and deliberate redemption into the existing authenticated session lifecycle. Require purpose-separated secret tokens, durable atomic consumption, expiry, tenant/account binding, bounded abuse controls, enumeration-resistant responses and safe redirects. Email scanners must not consume credentials merely by following a GET. Test replay races, mail/storage failures, stale accounts and session invalidation with deterministic delivery fixtures. |
The email-login item now has a local Auth/Mail candidate: explicit account opt-in, independent browser/email secrets, atomic opaque-session creation, durable SQLite/PostgreSQL state, fenced encrypted delivery and deterministic localized Mail templates. Local SQLite, native PostgreSQL, Chromium, fresh-process, database-restart and extracted-facade Auth/Mail contracts passed. Combined hosted source/package/coverage admission passed in PR #239. See the email-login contract. No owner-provider accounts were used.
The owner subsequently approved the five additional items below as the next implementation queue after the two priorities, emphasizing security throughout. All seven are targeted through September 23; completion still requires the advertised behavior and its acceptance evidence. Report any unfinished item before the freeze instead of silently including it in the supported release. The previously discussed metadata-only Studio messaging inspector remains a separate candidate.
| Priority | Additional candidate | User benefit and required boundary |
|---|---|---|
| 1 | Scoped, revocable application API tokens | Let integrations access explicit tenant/account permissions without borrowing browser sessions. Store token digests, bound scopes and lifetime, support revocation/rotation, and test cross-tenant denial and concurrent revocation. Existing provider credentials and application JWT helpers do not implement this lifecycle. |
| 2 | Shared PostgreSQL mail suppression | Let independent mail workers honor the same hard-bounce, complaint and explicit suppression state. Extend the current Memory/SQLite contract with atomic event replay handling, minimized state, restart/concurrency evidence and a final pre-delivery check; actual provider inbox acceptance remains separate. |
| 3 | Durable recurring schedules across application instances | Coordinate recurring occurrences through authoritative storage and leased dispatch into the existing queue/outbox. Define missed-run policy, cancellation, restart, stale-worker fencing and idempotent occurrence keys. Current per-process cron and delayed queue jobs are foundations; neither proves exactly-once external effects. |
| 4 | Durable outgoing application webhooks | Deliver application events to explicitly approved destinations with signatures, bounded retries and inspectable terminal failure. Compose the existing outbox, destination/SSRF policy and idempotency contracts; incoming payment-webhook verification is a different capability. |
| 5 | Resumable multipart uploads for private S3-compatible storage | Support larger attachments and interrupted uploads with bounded parts, authenticated tenant/object ownership, checksums, completion/abort and orphan cleanup. Extend the existing private-object adapter; this is separate from Bunny Stream resumable video uploads and requires native protocol evidence. |
The API-token item now has a local implementation candidate with exact scopes, owner-only management, revision-based rotation, account-epoch invalidation, SQLite/PostgreSQL authoritative checks and a Core machine-route verifier. Local SQLite, native PostgreSQL, fresh-process/database-restart and HTTP contracts passed, including the extracted facade/Auth/Core archives. Full workspace, coverage, security and hosted package admission were then still required; see the API-token contract.
The shared mail-suppression item has a local PostgreSQL candidate with keyed recipient/event identifiers, namespace-bound configuration, atomic replay/quota enforcement and the existing final-dispatch guard. Native independent-pool, restricted-role, failure/cancellation, worker/tenant and process/database-restart contracts passed, including the extracted facade/Mail/Core archive consumer. Full hosted admission was then still required; see shared mail suppression.
The durable recurring-publication item has a local Messaging candidate with PostgreSQL coordination, encrypted frozen occurrences, bounded UTC catch-up and coalescing, cancellation, fenced retries and metadata retention. Native tests passed real SQL rollback/lock deadlines, restricted roles, competing instances, fresh processes/database restart and durable broker replay after lost ACK. Messaging all-feature regression and strict Clippy passed; extracted archive consumers passed the native journey. Full hosted admission was then still required; see durable recurring publications.
The outgoing-webhook item has a local Messaging candidate with an encrypted SQLite outbox, approved destinations, fresh DNS validation and address pinning, exact-body HMAC signatures, bounded retry/manual recovery and permanent cancellation. Owned HTTP/TLS and fresh-process journeys passed receiver deduplication after lost responses, untrusted certificates, redirect/private address denial, SQL failure, retention and delivery uncertainty. These journeys also passed through extracted facade/Messaging archives. A reproduced SQLite lock-contention defect was corrected: lease deadlines are sampled after acquiring the write lock. Full hosted admission was then still required; see durable outgoing webhooks.
The private multipart item now has a local Core/facade candidate with encrypted object/tenant-bound resumable checkpoints, exact-size SHA-256-checked parts, remote receipt reconciliation, completion uncertainty and bounded abort/cleanup. Owned S3 tests passed independent clients, process/service restart and both endpoint profiles; HTTP failure tests reject tampering, foreign resources, DTDs, oversized XML, redirects and embedded errors under HTTP 200. Checkpoints require durable application retention/CAS; provider lifecycle expiration handles uploads whose initiation response was lost. Full hosted admission was then still required; see private multipart uploads.
All seven then passed combined hosted source admission in PR #239. This does not freeze their APIs or admit a crates.io release. The next priorities are package readiness, targeted security review and corrections before September 24.
The September 21 dependency refresh includes aws-sigv4 1.5.3 (with its
Smithy runtime API patch) in the multipart S3 evidence, and updates the Labs
structural parser to wasmparser 0.259.0. Its byte offsets now use a wider range;
checked subtraction preserves the existing 65,536-byte function-body limit.
A parse-only boundary regression and the ordinary runner tests/strict Clippy
passed locally. The Wasmi executor was pinned to 2.0.0 with its own validator;
hosted isolation and package source admission subsequently passed in PR #239.
The runner, and with it these dependencies, was removed from the workspace on
30 September 2026 before any independent isolation review or release acceptance.
New OpenTelemetry 0.33 proposals remain pending compatible adapter evidence.
The current tracing-opentelemetry 0.33 manifest
depends on OpenTelemetry 0.32; a registry probe found no 0.34 adapter release at
this observation. Do not treat those five bot PRs as integrated or combine
incompatible public types. The next default-branch Dependabot configuration groups
the OpenTelemetry and tracing adapter family into one reviewed proposal, without
ignoring updates or weakening required checks. Recheck upstream availability
before the validation freeze. The v12 maintenance proposals remain separate,
with the same family grouping to avoid incompatible individual updates.
Repository housekeeping on September 21
main already contains the v13 promotion from PR #238; v12 is the stable
maintenance line and gh-pages serves the site. The obsolete v13 reference
(d246ea07) was retired after proving it is an ancestor of main (0ce3306d).
Fourteen additional remote feature references were removed only after confirming
their complete history remains in main or the consolidated PR #239. Local
worktrees and unique local commits were retained. Both maintained branches then
required the existing 43 checks, CodeQL Rust and JavaScript analyses, and the
separate CodeQL findings check from GitHub’s security app (46 in total).
Dependabot PRs #244 and #247 are superseded by the AWS and Wasm parser changes
in PR #239; those compatible updates passed source admission. Individual
OpenTelemetry PRs #241–243, #245–246 and #248–250 are closed as incompatible
pending the coordinated adapter upgrade described above. Registry inspection
still finds tracing-opentelemetry 0.33.0 as the latest stable adapter, requiring
OpenTelemetry 0.32. No OpenTelemetry 0.33 adoption is claimed. Compatible v12
maintenance updates are consolidated in PR #240, preserving #251’s complete
history. Its SES constraint check now expects the 1.6.4 pin while retaining the
historical 12.0.0 SemVer baseline’s separate 1.6.3 guard. The combined maintenance
commit passed all 46 checks v12 requires and complete CI/SemVer workflows. PR #240
merged normally into v12 at 6dde0cb1 on September 22; no package was published.
Scorecard’s binary finding points to the trusted checked_sum.wasm test vector,
not application code. Rebuilding its adjacent Rust source with pinned Rust 1.96
reproduced every byte; the Labs acceptance job repeated that check until the
fixture, the job and the runner were removed on 30 September 2026.
The scanner annotation records test data without suppressing SARIF output.
The historical SAST finding persisted in Scorecard run 35675329836, reporting
15/30 despite verified successful CodeQL checks on all three PR heads associated
with the sampled 30 commits: #228 (977e40a3, six commits), #236 (54821688,
15 commits), and #238 (87a3bdbf, nine commits). Both REST check runs and GraphQL
check suites report success. Scorecard v5.5.0 reads only the first 30 check
suites; #228’s CodeQL suite is 32nd, demonstrating incomplete detection. The
exact remaining discrepancy is not established. The alert is classified as a
false positive for this reviewed snapshot; it does not imply that every
intermediate commit was scanned independently. Required CodeQL jobs then
enforced both language analyses for PR admission. (main now runs CodeQL after
each push instead of on its pull requests, and codeql.yml is one of the
release-required workflows; v12 still requires both analyses.) Scanner output
remains enabled.
The first combined PR #239 campaign exposed a Redis TLS provider-selection
panic and omitted public-guide doctest registrations. The corrected Redis
adapter preserves an installed provider and installs ring only when none exists;
the actual Redis/TLS/restart suite and strict targeted Clippy pass locally.
All three newly registered guide examples compile with their real features.
Windows Labs initialization also returned Storage after about three seconds.
The unchanged store passed both its local four-test suite and the fresh Windows
workspace run at 5c6a8136; the original transient cause is not established.
Redis/TLS, strict quality, coverage, isolated Labs and the actual archive/installed
CLI campaign 35676290050 also passed on that snapshot. Other jobs were still
pending, so this is not full-source or release admission.
The same snapshot’s successful CodeQL analysis workflow produced two high-severity
XSS findings (#348 and #349) in the test-only email-login HTTP fixture. All dynamic
HTML attribute values now use Core’s HTML escaping function. The Chromium test
parses both actual rendered forms with hostile quote/tag/handler input and checks
that field values round-trip without injected elements or event handlers, in
addition to the original SQLite/PostgreSQL sign-in, CSRF, replay and logout
journeys. CodeQL confirmed both findings fixed on head 7776ccda, with zero open
PR alerts. The CI
observer now checks the separate security result and PR-ref alerts; success of
the analysis workflow alone never counts as a clean security scan.
Execution order may respond to measured implementation and validation cost. Do not replace full journeys with mock-only placeholders to increase the feature count. Keep September 24–25 for combined validation and September 26 for final adjustments and separately authorized publication. No owner/provider account tests are authorized; disposable local services and deterministic protocol fixtures remain available. Architectural/API decisions must be recorded in the SST as each candidate becomes implementation work.
Seven-increment source admission and package preparation on September 22
PR #239 merged into protected main
at 9f543ed5326029f8e4fbf0693dd7faab9288a526. Its admitted head 7776ccda and
GitHub test merge 3a84b107 have identical tree abbbade9.
- All 46 checks
mainthen required and 24 PR workflows passed. Additional S3 and isolated Labs jobs passed, both review threads were resolved and CodeQL had zero open PR alerts. - Coverage passed both unchanged 90% floors: 108639/120114 whole-repository lines (90.4466%) and 80139/87903 governed library lines (91.1675%, 627 files). Downloaded nextest evidence records 2744 all-feature cases in 234 suites with no failures, errors or skips. Default/browser/database profiles ran separately.
- Archive acceptance audited 21 diagnostic archives, exercised archive-only consumers and compiled all six blueprints with the installed CLI. Diagnostic archives do not establish release eligibility for the four packages then excluded from publication.
The next publication-configuration candidate adds standalone rullst-supervision
and rullst-media to the synchronized inventory (19 packages). Both preserve
empty default features and explicit application authorization. Their archive
consumers and the generated LMS supervision journey now run in the ordinary
release rehearsal, without the diagnostic flag. The CLI upgrade inventory includes
both names. Supervision’s stable CLI generator pins the matching registry
version; prerelease evaluation still requires the matching local source and rejects absent or wrong
source before applying edits. The reviewed initial-registration allowlist
contains Privacy, Supervision and Media; the registry ownership check still rejects another owner.
A read-only crates.io check on September 22 found all three names unregistered.
The normal release workflow still rejects unregistered names; the allowlist does
not enable a bootstrap credential path. No initial registration, tag, release
approval or publication has occurred. (Update: make:supervision and its
generated LMS journey were later removed with the complete Academy scaffold.
The release rehearsal now tests the extracted Supervision archive directly and
compiles only the SaaS age/privacy opt-ins.) (Update: all three names were first
published in 13.0.0-alpha.1 on October 11, 2026.)
rullst-labs and rullst-labs-runner remain publish = false, outside the release
inventory and available only through explicit diagnostic packaging. Their
independent isolation-review requirement is unchanged. (Update: the runner
candidate was removed from the 13.0 workspace on 30 September 2026; see the
M40 row below.) The publication changes
require their own hosted campaign and actual archive acceptance; previous-source
results above do not admit them. Bunny account/CDN acceptance remains unvalidated.
(Update: on 8 October 2026 the maintainer admitted rullst-labs to the release
inventory as 13.0.0-alpha.1; it packages contracts only, and the removed runner
is not published.)
This candidate also incorporates the newly opened Dependabot PRs #253 and #254: SES 1.136.0 and compatible AWS runtime updates, tokio-rustls 0.26.5, and immutable install-action 2.87.16 pins. Their declared Rust requirements stay below the 1.96 MSRV; the combined platform, feature and archive campaign is still required. The corresponding v12 PRs #252 and #255 require their own maintained-line compatibility checks.
The framework README now highlights the separately maintained Rullst Academy. Its public landing page returned HTTP 200 on September 22 and describes free Rust/Rullst and broader technology learning in Portuguese, English and Spanish. This is a product link, not validation of private accounts or application flows.
Depth before additional optional features
The owner asked to pursue a complete, comprehensive implementation of Bunny and Labs before moving to other new capabilities. Finish the supported user journey, its failure/recovery paths, integration documentation and executable acceptance before widening the provider, language or backend matrix. This changes priority and the intended outcome; it is not evidence that either implementation exists or a promise to finish both by the 26th. The feature freeze and validation dates remain unchanged.
| Order | Intended usable outcome | Completion boundary |
|---|---|---|
| 1 — Bunny Stream | An instructor manages a course-owned video through creation, authorized resumable upload, processing, publication, metadata changes, withdrawal and deletion; an entitled learner obtains and renews private playback access. | Complete the selected provider lifecycle, durable ownership/state, notification reconciliation, retry/recovery paths, application/browser consumer, automated protocol tests and operations guidance. Live-account interoperability remains unvalidated under the owner’s no-live-testing constraint. See the detailed scope. |
| 2 — Labs | An instructor defines a versioned exercise and grader; an authorized learner submits, executes and receives a bounded result through a separately deployed runner, with cancellation and recovery. | Complete the contract stage, then pursue one named isolated backend and a supported exercise/toolchain profile with actual execution, deterministic grading, isolation and failure tests. Contracts and a mock alone do not complete this outcome. See the delivery target. |
Divide implementation into small reviewable commits and PRs without declaring the journey complete early. While hosted checks run, advance related documentation, recovery tests and packaging. Move implementation effort to the next journey after the preceding supported lifecycle and focused acceptance pass; required hosted admission may continue independently. Do not open an unrelated optional feature while useful completion work remains.
If a journey cannot meet its acceptance by the evening of September 23, report the precise unfinished behavior and keep it out of the stable supported scope. An independently useful contract package may remain explicitly experimental, but cannot be presented as a completed execution product. Preserve the reserved validation days and the isolation, provider-evidence and publication requirements.
A provider adapter advertised as live-validated requires environment, protocol and acceptance evidence. The owner’s later direction allows opt-in integrations with automated protocol evidence and explicit unvalidated-provider status; do not use personal/live libraries for testing. Provider availability does not suspend independent framework work. The final privacy package scope must accurately identify every supported method; unimplemented facial or provider methods cannot be advertised as functioning production verification. The package remains unpublished until its scoped consumer/state/API and release admission criteria pass. (It was first published in the 13.0.0-alpha.1 prerelease on October 11, 2026.)
Blueprint scope and automated acceptance
On September 20 the owner prioritized implementation and testing over extending every new feature to every blueprint. Keep existing working integrations and their regression tests. For new work, choose the smallest real consumer that exercises the capability: supervision in LMS, billing entitlements in SaaS, and a focused server/client fixture for an API contract. A public generator must still test each supported output shape; do not advertise an untested shape. Additional blueprint coverage belongs in the release only when it demonstrates a different supported contract or provides clear product value. Opt-in features must remain opt-in, and source/manifest, authentication, persistence and packaged installation checks cannot be replaced with mock-only examples.
Since 13.0.0-alpha.4 cargo rullst new keeps three starters (Blank, API and
SaaS). The LMS, ERP and Portfolio blueprints became workspace examples under
examples/ with their own tests, the Blog blueprint was dropped in favor of
the existing blog showcase, and the CLI’s generated-project gates cover only
the three starters.
Human exploratory testing is not assumed to be available during this delivery window. Use automated unit, integration, real-database, HTTP/browser and failure tests appropriate to each supported feature. Record environmental and usability limits explicitly; neither AI authorship nor a passing suite proves every deployment. Missing provider/device/isolation evidence stays missing and limits the supported scope. Defer optional scope and non-critical improvements when needed, but fix known authorization, privacy or data-integrity failures before admission. The possibility of a later patch does not turn a failing contract into an accepted release.
Starting evidence
- All sixteen v12.1.0 packages are published from
b62390b4; the release record retains their receipt. - The post-release closeout in
PR #217 merged into
mainat184bc1f7on 20 September UTC, after 84 successful checks and three skips. It records publication, repairs mutation discovery and corrects the SemVer baseline resolver; the published source and artifacts remain unchanged. - The integration candidate carries that exact published runtime source into v13, preserving the unpublished privacy crate and Labs/Verus plans. The combined source needs fresh CI; the v12.1.0 results do not certify v13.
- PR #218 admitted the integration
and SQLite/PostgreSQL replay foundation into
v13at7f48d882on 20 September UTC. Its reviewed head03601566passed 85 hosted checks with four declared skips, including workspace tests, strict Clippy and the exact coverage floors. Three remaining review threads referred to the already individually reviewed test-only alerts 327–329 and were resolved after rechecking those fixtures and their existing dispositions. The later native declaration, challenge transport and generated consumers require their own hosted campaign; this admission does not certify those subsequent changes. - The initial privacy foundation passed 16 integration tests and one doctest. The persistence change adds asynchronous verification, trusted clock rechecks and optional shared-local SQLite claims. Its 29 local integration tests cover independent pools, a fresh process, reopen, concurrency, quota, cancellation and failure paths. Hosted candidate validation and a live age provider remain outstanding; this is foundation evidence, not end-to-end production acceptance.
- The PostgreSQL candidate passed focused real-database bootstrap, restricted runtime-role, multi-pool replay/quota, expiry during lock wait, schema/durability drift, cancellation and server-restart checks. CI and coverage explicitly run that disposable-database contract; combined candidate evidence is still required.
- The next local increment adds native declarations and authenticated challenge transport, plus an opt-in generated SaaS dashboard journey. Local checks passed 42 privacy integration tests, one unit test and five doctests; the disposable PostgreSQL lifecycle and restart contract also passed explicitly. A generated SaaS executes real session/user lookup, production browser middleware, explicit choice and durable replay denial; a second process verifies missing-key denial, and the PostgreSQL consumer profile compiles. Installer tests cover formatted source, changed authentication, source selection, preserved edits and rollback after a write failure. Strict local CLI Clippy and book/link checks passed. The follow-up LMS consumer also passes a generated SQLite journey: bounded school selection, real membership/role resolution, header selection carried into a browser form, cross-school/user denial, replay and revoked membership. Its declaration writes no subject-age or guardian-consent row and does not install age-state middleware around other learning routes. Hosted admission and broader purpose/rights workflows remain outstanding; these checks do not establish deployed browser/provider acceptance.
- The initial CodeQL scan’s 26 age-replay alerts were individually reviewed: 23 intentional integration-test identifiers, two policy-field data-flow conflations and one unmodeled OS-random buffer overwrite. The review receipt binds those dispositions to the analyzed source. No query, workflow or source/test path was excluded.
- The initial review found main-only release admission/security filters and an
unprotected
v13branch. The preparation change binds each major to its release branch and enables the missing automatic checks. On 20 September UTC, hosted v13 protection was enabled and read back with 43 required existing checks; the observed profile records its scope. Combined hosted evidence and packaging remain pending. - The v12.1.0 post-release mutation campaign is informational. Its repaired controller discovers the measured source’s complete inventory and limits the 80 shards to four concurrent jobs. Findings are reviewed separately from v13 readiness, with security-relevant corrections carried to each affected line.
Release priorities and acceptance
The next local increment adds two privacy fuzz targets to the v13 release
inventory (42 total; v12 remains 40). Two five-minute ASan diagnostics completed
without a reproducer: 1,442,644 challenge-token inputs and 929,346 attestation
inputs. These are local diagnostics, not the complete hosted release campaign.
The independent consent/consent-sqlite foundation now provides explicit
purpose/notice choices, revision-bound grants, unconditional withdrawal,
default denial and per-action checks. Its shared-local adapter retains bounded
state and clock metadata, refuses implicit initialization/repair and preserves
withdrawal across pools and processes. Local contracts cover stale forms,
version/tenant/subject boundaries, capacity, clock/expiry, cancellation and
faulty adapters. The opt-in make:privacy consumer now supplies authenticated
preferences, an optional name-based greeting and a direct own-account profile
export through a concrete parameterized SQL adapter. Local generated SaaS/full
LMS HTTP fixtures passed both age/privacy installation orders, explicit choices,
withdrawal and stale grants, CSRF, account/session/school changes, expiry, unknown
and oversized inputs, and missing/failed consent state. The profile export stays
independent of optional-consent storage and form keys and never claims to export
other application records or complete queued rights requests. A normal
PostgreSQL-primary SaaS profile also compiles; live primary-database isolation
and deployed browser acceptance are separate from that compile check.
These HTTP fixtures found a Core header composition defect: the outer layer
replaced an endpoint’s no-referrer. Core and both Security header layers now
share a narrow preservation rule; 36 local composition cases cover layer order,
configured defaults, weaker handler policies and duplicate values. The prior
PR #219 campaign finished with 84 successful checks, four skips and one macOS
unit-fixture failure caused by the system temporary-directory symlink. Its
fixture now resolves the same root as the CLI, with an explicit child-symlink
rejection regression. Four new CodeQL policy-threshold conflations were
individually reviewed and dismissed; the
declaration review receipt
binds those decisions to the old analyzed source. No query was disabled.
PR #219’s corrected head d783f3b7 subsequently passed 85 hosted checks with
four declared skips, including all-feature workspace tests, strict Clippy,
coverage floors and the generated privacy consumers. It merged into v13 at
df589770 on 20 September UTC. This admits that privacy increment; it does not
certify the later adoption, Android or entitlement changes.
The next adoption increment gives the existing sixteen release packages and
their internal requirements the explicit development version 13.0.0-alpha.1.
Privacy remains unpublished. Migration catalog v2 admits source major 13,
retains target-major selection and downgrade rejection, and invalidates old
preparations. Local updater contracts exercise a distinct 12.1.0→13 resolution,
review, stale-input rejection, application and recovery. Both executable names
report the actual source version; prerelease tests keep the explicit opt-in.
The adoption guide inventories the current additive changes
and distinguishes tiny updater protocol packages from actual generated
framework consumers. Combined hosted acceptance and an unpublished package
rehearsal remain outstanding.
The development train passed 348 local CLI unit tests, 46 updater process contracts, 32 command/scaffold contracts and the generated foundation matrix (Blank/API, SaaS and full LMS, including LMS authorization negatives). Release preflight still identifies sixteen publishable packages with a consistent version and topological order; it does not admit the unpublished privacy package. Strict CLI all-feature/all-target Clippy and production panic checks passed. These are local checks, not the outstanding combined hosted/package campaign.
The Android candidate now binds one fresh bounded APK to the application’s
expected certificate through the SDK verifier, checks its final bytes, rejects
stale/ambiguous outputs and withholds captured signing-tool diagnostics. Native
process fixtures exercise success, explicit selection, wrong certificates,
missing/stale/oversized/changed files, verifier/build failures and output/time
bounds. The hosted Android workflow now exercises this CLI against a real
signed release and independently checks its certificate and receipt. That new
hosted SDK job passed on PR #220 head 52657955: it compiled and verified a
signed release APK through the CLI and the independent SDK check
(run 35497371156).
The rest of the combined campaign remains pending. This is SDK interoperability
evidence; device/store acceptance remains separate.
The PR #219 follow-up CodeQL analysis reported one additional threshold-to-nonce conflation in the isolated privacy fuzz helper. Alert 338 was individually reviewed against SARIF 1806278303 and dismissed; the fuzz-policy review receipt records the exact source and reasoning. The policy literal is 18; the fuzz-only nonce is a separate deterministic fixture, and production issuance retains OS entropy. No source path, query or fuzz target was disabled.
PR #220 head 52657955 passed the real SDK signed-APK job, but its coverage
and macOS CLI campaign exposed two Android fixture/build-timing issues. The
follow-up preserves OS process helpers in the fixture PATH and compares artifact
timestamps against a temporary filesystem anchor. Local native selection and
redaction scenarios passed after correction; a fresh combined campaign is required.
PR #220 admitted the adoption,
Android and conditional supervision-planning changes into v13 at 6ccbe2a7
on 20 September at 14:20 UTC. Its exact head ee4f46df completed 86 successful
checks and four declared skips, with no unresolved review threads. This includes
the full OS/workspace matrix, strict Clippy, coverage and the real SDK signed-APK
job. The initial campaign’s changelog, filesystem timing and terminal prerelease
fixture failures were corrected without weakening their checks. This admission
does not certify the later entitlement, context, API, Verus or packaging changes.
The entitlement candidate adds a typed current-state gate in Capital and an authenticated SaaS billing report. It requires an exact server plan allowlist, fresh revision-fenced Stripe reads, a matching live/sandbox mode and an active, unexpired subscription. The six focused local contracts passed tenant/owner, status/mode, revocation, expiry, stale/future clock and adapter-failure cases. The existing generated SQLite/Turso billing contract also passed. The additional generated SaaS HTTP/reconciliation fixture passed all four configuration profiles, including real session/owner checks, a delayed active read losing to revocation, the 20-second provider deadline and strict generated all-target Clippy. Its test-only provider boundary is explicit; it is not a live Stripe acceptance run. CMS projections and offline billing fixtures cannot grant the new report. Local validation also passed the public doctest, strict Capital default-feature all-target Clippy, CLI all-feature/all-target Clippy, production panic checks, 353 CLI unit tests (one parent-owned child fixture ignored by the outer harness), 20 command/scaffold contracts and book/local links. Combined hosted acceptance remains outstanding.
The project-context candidate replaces raw source concatenation with a bounded,
versioned file/dependency/configuration-key inventory. It creates common project
instructions only when absent, preserves user AGENTS.md, supports non-writing
freshness checks and records source scope/exclusions explicitly. Local contracts
cover private-value omission, links, size/depth/count budgets, stale inputs,
legacy migration, conflicting output and all six generated blueprints. Semantic
route/authorization inference and task-specific context shards remain follow-up
work; the guide states the exact candidate boundary.
The schema-first API candidate now generates Rust DTOs/operation codecs and a strict TypeScript client from one bounded OpenAPI 3.1 JSON profile. Seven generator contracts cover unsupported shapes, links, duplicate keys, collisions, regeneration and freshness. Local validation passed 371 CLI unit tests (one parent-owned child fixture ignored), strict all-target Clippy, the real generated Rust/TypeScript HTTP consumer, ten existing command contracts and the six-blueprint context journey. The HTTP fixture proves typed statuses, Unicode, optional/null semantics, exact safe integers, nested nullable arrays, unauthenticated/cross-owner denial and adversarial transport rejection. A GET-only Rust module also compiles with strict Clippy. Hosted Linux CLI acceptance requires the pinned TypeScript compiler and the actual HTTP journey; combined hosted admission remains pending.
The Verus pilot has a local candidate for the unchanged production
AgePolicy::permits predicate. Rust syntax extraction checks the actual package,
module path, enum/type/signature domain and body; the verifier covers all nine
risk/method combinations. Both local runs verified the predicate and rejected
all three deliberately incorrect implementations. They took about 12.1 seconds
including complete pinned-tool validation, with prover peak RSS below 292 MiB.
The runtime age suite and MSRV 1.96 check passed. The prepared manual workflow
is separate from release admission; clean hosted evidence and any promotion
remain pending. See the proof boundary; this does not prove
age evidence, expiry, replay state or application authorization.
The next packaging candidate adds rullst-privacy as the seventeenth package,
with its license, a bounded source archive, opt-in rullst::privacy features,
coordinated upgrade inventory and registry-shaped age/privacy generators.
The local seventeen-archive packaging/content audit and the durable facade
reopen test passed. Both generated SaaS/LMS privacy journeys passed, including
the registry-shaped LMS dependency and the existing local-source SaaS profile.
Context freshness for both new commands, 372 CLI unit tests (one parent-owned
child fixture ignored), strict CLI Clippy and the minimal facade panic gate also
passed. A separate consumer compiled only the extracted framework archives and
passed that durable facade test. All 42 fuzz targets’ eleven locked dependency
graphs resolved offline without running a new fuzz campaign.
Combined hosted acceptance remains outstanding. All seventeen local archives
were subsequently generated from clean commit 30e2f753; their source identity,
licenses and excluded secret/database paths were audited. The archive-only
privacy consumer and privacy publication dry run passed without uploading a
version. These are local rehearsals, not final release receipts.
The archive-only hosted test then compiled the installed CLI’s SaaS/LMS opt-ins
and executed the same durable facade contract from extracted packages. It
passed at 365d2252 in run 35520146394
on September 20: seventeen package archives were audited, the durable facade
test passed and all six installed-CLI blueprints compiled. This diagnostic does
not replace the final full/native release campaign or publication authorization.
A crates.io read on 20 September returned 404 for rullst-privacy;
initial registration and Trusted Publishing configuration remain required.
The stable publisher continues to reject an unregistered package.
The ownership-policy validator now checks each proposed bootstrap name against
the inventory, with unknown/duplicate/malformed-name negatives; the empty
bootstrap allowlist and the stable publisher’s refusal remain in force.
PR 220’s Linux terminal fixture also needed an explicit prerelease dependency
requirement and --prerelease when exercising the alpha CLI. All four local
terminal scenarios passed after that correction: decline copying, decline
execution, apply/recover, and a failing application. The corrected ee4f46df
head then passed its own combined hosted campaign, as recorded above; previous
Android/macOS passes were not substituted.
On 20 September at 07:31 UTC, the first four v12 mutation shards had hit their 330-minute execution budget; their partial artifacts were retained. For example, shard 2 had executed 72 of 220 scheduled mutations (40 caught, 30 surviving, two unviable), so it is explicitly incomplete. Surviving mutations require individual review and are not automatically confirmed runtime defects. The remaining campaign continues; partial results must not be reported as a complete mutation pass, and any later continuation must preserve source/inventory identity.
The first combined PR #221 campaign
at 4773171a passed 85 checks, including the OS/workspace and generated consumer
matrix, strict Clippy, coverage, feature boundaries and MSRV. Two inventory checks
failed: the README omitted the added Verus workflow, and the observational
scorecard omitted the seventeenth package. The correction retains both checks,
adds a conservative unpublished privacy ceiling and preserves its planning floor.
The corrected head requires its own hosted admission. A new explicit package-only
diagnostic runs the existing archive/installed-CLI acceptance without repeating
the OS matrix; a regression proves that this subset cannot admit a release.
The corrected 365d2252 head subsequently passed 87 hosted checks, with four
declared skips and no unresolved review threads. PR #221 merged normally into
v13 at 48a6ee51 on September 20 at 17:23 UTC, with all 43 required checks and
administrator enforcement preserved. Its seventeen-archive/installed-CLI
diagnostic passed separately as recorded above. This admits the combined
application/privacy candidate; the final full/native/security release campaign
and initial new-package registration remain outstanding.
The first conditional supervision implementation now has a separate unpublished crate, bounded domain contracts and shared-local SQLite state. Its focused local tests cover explicit acknowledgement, scoped/revocable authority, session transitions, event bounds, parental course windows, retention, fresh-process reopen, concurrent revisions, lock-wait expiry, cancellation and corrupt rows. An explicit full-SQLite-LMS generator now installs the original learning-service gate, signed/scoped SSR forms, local operator provisioning and a bounded visibility collector. Three local CLI integration tests pass, including both privacy/age composition orders, public-profile compilation, the real HTTP lifecycle and Chromium keyboard/no-JavaScript/visibility controls. A start form is bound to the last retained session revision so an old acknowledgement cannot silently start a new session after end. The generated application passes all fourteen original LMS library tests and strict production Clippy/zero-panic checks, and all 376 CLI library tests pass. These local results do not admit the package: full workspace regression, installed archives and hosted acceptance remain outstanding.
PR #222 head 20a9fca7 subsequently passed 86 hosted checks with four declared
skips and no unresolved review threads. Its independent packaged-distribution
run 35534311217 also passed at that exact head. The supervision source merged
normally into v13 at 081c3805 on September 20, 21:14 UTC, preserving all 43
required checks and administrator enforcement. This admits that source increment;
stable package registration/publication and the final combined v13 campaign are
still outstanding. The next shared-passkey candidate is under its own hosted
campaign; the deployment/body and local diagnostic increments remain separate.
The local deployment diagnostic passed seven executable contracts, including a
generated SaaS, and two additional literal-parser/boundary contracts. All 376
previous CLI unit tests passed; strict all-target CLI Clippy, book/local links
and architecture checks passed. A bounded six-mutation decision sample was fully
caught. Inspection also reproduced acceptance of the public .env.example
application-key placeholder in Auth; explicit rejection now passes the runtime
regression, 65 Auth unit tests and three key-resolution process contracts, with
strict all-target Auth Clippy. These are local candidate results; hosted and
installed-package acceptance remain outstanding. The corresponding stable-line
backport still needs preparation and its own checks; published artifacts remain
immutable.
The next four source increments were merged into v13; their evidence is
recorded individually below:
- PR #223, shared PostgreSQL passkey
ceremonies, merged at
b354d67c. The exact15addc3dcandidate passed hosted checks and archive acceptance. - PR #224, HTTP response-body draining
and the Caddy/Redis deployment contract, merged at
a4a3b3e6. The exact2e348f96candidate passed hosted checks and archive acceptance. - PR #225, deployment diagnostics and
rejection of the public Auth key placeholder, merged at
873bc14a. The exact45a573cccandidate passed hosted checks. The archive job in run 35554979487 was skipped, so that run is not archive acceptance. Correctly selected exact-commit validation subsequently passed on September 21 at 06:32 UTC, including the actual archive audit, extracted consumers and all six installed-CLI blueprints. This repairs that missing gate retrospectively. - PR #226, transparent exam
observations and bounded analysis adapters, merged at
bb71dad8. The exactae9b6d57candidate passed 86 hosted checks. The archive job in run 35558912935 was skipped, so that run is not archive acceptance. The correctly selected exact-commit validation subsequently passed on September 21 at 06:27 UTC, including the actual archive audit, extracted consumers and all six installed-CLI blueprints. This repairs that missing gate retrospectively.
The September 21 audit found that these last two merges preceded their required installed-archive validation: a successful workflow was incorrectly credited despite its skipped archive job. Both gaps are now repaired by the actual exact-commit runs recorded above. These retrospective results do not erase the missed premerge gates; subsequent source admission requires checking the named archive job itself before integration.
These merges do not publish packages or replace the final combined release
campaign. Bunny subsequently passed its own source admission in
PR #227: the exact b21d52e7 candidate
passed all 87 current checks, the actual
installed-archive job,
and review with no unresolved threads, then merged at 618655c1.
PR #228 subsequently admitted Labs
source at 278a115b. Candidate 977e40a3 passed all 24 native shards, 43
branch-required checks, actual isolated execution, the actual installed-archive
job and both 90% whole/library coverage floors. Its non-required Codecov patch
check remained at 83.45936%, below its 90% target; the
profile evidence records this
limitation without exporting worker profiles or relaxing isolation. Independent
isolation review and final release admission remain outstanding. Stable-line
backport preparation remains separate as well.
The September 20 observation extension now adds selected browser categories, capture-status contracts and bounded analysis adapters to the supervision candidate. Its five generated CLI/composition/process contracts pass with real Chromium, alongside 378 CLI unit tests, isolated feature checks, strict Clippy and an offline simulated adapter example. A targeted nine-mutation guard sample is fully caught. No camera/audio model, automatic misconduct determination or live-provider validation is claimed. Hosted workspace checks passed in PR #226; the missing archive gate subsequently passed as recorded above. The final release campaign remains required. See the integration guide.
| Priority | Deliverable | Acceptance before calling it complete |
|---|---|---|
| P0 | Published v12.1.0 corrections integrated without losing v13 work | Review conflicts; retain the stable runtime changes; pass the combined workspace tests, strict Clippy, format and feature/consumer checks. |
| P0 | Protected v13 verification and publication path | Explicit major-version/branch policy, protection equivalent to the stable line, complete required workflow/job inventory, negative wrong-branch/tag/evidence tests and an unpublished package rehearsal. Keep v12 maintenance independently releasable. |
| P0 | Usable proportional age-assurance boundary | SQLite shared-local and PostgreSQL multi-host replay protection, authenticated tenant/session/action binding, a first-party declaration journey only where policy permits it, explicit method strength and fail-closed outage/expiry/replay behavior. Production must reject mocks, process-local state and unsupported stronger methods. |
| P0 | First complete privacy journey in a generated consumer | Explicit purpose/version choices, withdrawal enforced on subsequent optional processing, one authorized rights workflow with actual adapter effects, tenant isolation and documented retention/restore boundaries. Do not count a request row as completed export or erasure. |
| P0 | Safe 12.1→13 adoption | Inventory actual compatibility changes, implement only supported migration rules, run generated SaaS/LMS consumer fixtures and prove review, stale-input rejection and recovery. A major-version flag alone is not a migration. |
| P1 | Server-side SaaS plan entitlements | A typed authorization gate bound to authenticated tenant, subject, feature and validity, consumed by a generated SaaS route. Deny expired/revoked or cross-tenant grants. Payment redirects, mock results and unverified events must never grant access. Keep subscription reconciliation explicit. |
| P1 | Typed API and first TypeScript consumer | One explicit schema source for a bounded supported set of request/response shapes, parameters and errors; compile and execute a generated consumer against its server fixture. Prove nullability, serialization, error handling and denied access; reject unsupported schema shapes rather than emitting guessed types. |
| P1 | Android artifact verification through the CLI | Select the intended release output, run signature verification and bind the signer to the application-owned certificate. Reject missing, ambiguous, stale, unsigned or wrong-key artifacts; test process failures and redaction. Validate a real generated APK in hosted Android CI; device/store acceptance remains separate. |
| P1 | Better application context for maintainers and assistants | Accurate generated AGENTS.md, a bounded deterministic project map, configuration key names without values, explicit file/secret exclusions, freshness information and executable regression fixtures. |
| P1 | Focused Verus pilot | One production age-policy property with checked linkage, pinned tooling, negative controls and measured cost. No framework-wide verification claim or automatic expansion to every crate. |
| First conditional extension | rullst-supervision candidate | One generated LMS journey with visible sessions, explicit permissions, independently established guardian/reviewer authority, effective revocation, bounded events/retention and tested application-side restrictions. Prove cross-school/subject/reviewer denial and browser behavior. Client observations cannot prove misconduct or automatically change grades. Device-wide control, camera inference and a mock-only package do not satisfy this scope. |
P0 items have precedence over new integrations, cosmetic rewrites and expanding the number of crates. Each implementation should remain a small reviewable change with its own tests and migration/documentation updates. The acceptance column is a requirement, not a description of code already present.
Coverage of the wider roadmap
Additional increments approved on September 21
The owner approved the following order after the Bunny/Labs source work. Finish each supported journey and its acceptance before expanding its advertised scope. The six implementations passed the source admission recorded below; their combined dependency validation and final release admission remain separate.
| Order | Increment | Completion boundary |
|---|---|---|
| 1 | Private S3/R2 storage in existing Core/facade APIs | Upload, download, metadata, deletion and temporary private GET grants; current tenant/owner authorization, bounded failures, an independent disposable S3 service and an extracted-package consumer. See the candidate contract. |
| 2 | Active-session inventory and effective revocation in Auth | Authenticated inventory, expiration, selective logout and logout of other sessions with rejection by actual request verification across processes. Preserve account/tenant isolation, recovery and fail-closed storage behavior. See the candidate and migration contract; source/package admission passed in PR #236. |
| 3 | One remote Messaging adapter | Redis Streams selected; see the candidate contract. Prove publication acknowledgement, restart/redelivery, competing consumers, retry/DLQ and outbox composition against a disposable broker. Source/package admission passed in PR #236. |
| 4 | Reconnection and recovery for server-driven real-time interfaces | The opt-in Core/facade candidate uses full snapshots, current authorization, transactional expected revisions and bounded connections, actions and slow peers. Actual WebSocket and Chromium tests cover conflicts, uncertain writes and application restart without automatic mutation replay. See the recovery contract; source/package admission passed in PR #236. |
| 5 | Operation tracing across processes | Core/facade’s optional candidate has explicit parent trust, approved operation labels, minimized bounded OTLP export and an owned lifecycle. Separate Messaging processes reach a standard TLS collector with verified ancestry; local failure/queue/TLS contracts pass. See the tracing profile; source/package admission passed in PR #236. |
| 6 | An application-driven ORM increment | Transactional partial updates selected: merge a submitted lesson/profile patch into the current row and reuse policy, hooks, encryption, atomic audit and post-commit effects. Preserve the caller model on rejected operations and add explicit transaction support. See the candidate contract; local database/cancellation/cache/Scout and extracted-consumer checks passed; source/package admission passed in PR #236. |
Six-increment source admission on September 21
PR #236 admitted all six increments
at source 548216881e062b83d6b73a824abe140936aca5b8. It merged normally into
protected v13 at d246ea072a0bddd1ced86471c49ffdb22d2b96cc; its tree matches
the tested merge candidate. Evidence for this source:
- Rust CI: all 43 protected requirements passed, including the Linux/macOS/Windows matrix, strict Clippy, format, MSRV and feature boundaries. Private S3 and isolated Labs acceptance also passed. All 21 relevant PR workflows completed successfully.
- Coverage: 103382/114571 whole-repository lines (90.2340%) and 74906/82383 governed library lines (90.9241% across 581 files). Both unchanged 90% floors passed; the downloaded summary was independently checked. The all-feature nextest artifact records 2701 cases with zero failures, errors or skips; default, browser, database, collector and isolated-controller profiles ran separately.
- CodeQL: Rust and JavaScript scans bound to the exact merge source completed with zero open findings. All seven review conversations were resolved.
- Packaged distribution: 21 candidate archives audited, archive-only consumers exercised, and all six blueprints compiled through the isolated installed CLI. The actual package job executed; skipped diagnostic jobs were not credited as PR checks.
This admits the documented source contracts, not a crates.io release or every parent roadmap milestone. No owner/provider accounts were exercised. The Labs profile retains its separate independent isolation-review boundary. The new main-line dependency combination must pass its own exact-source campaign.
After this approved round, report the delivered scope and remaining time to the owner before selecting another round. September 24–25 remain reserved for combined validation; September 26 remains the conditional final-adjustment and publication day. Publication is excluded from the current implementation goal.
The master roadmap owns the current status of the framework milestones and the separately governed M31 programme. Its milestones differ substantially in scope and remaining effort; counting their status labels cannot tell us whether this release adds “10% of all future work”. Nor does finishing one increment close its entire parent milestone. The wider v13/v13+ programme remains available for subsequent minor releases.
Items deferred beyond the September 26 target do not automatically become v14 work. Compatible additions can ship in 13.1, 13.2 and later minor releases; breaking changes belong in a future major under the compatibility policy. The current major’s concrete CLI/configuration change and the limits of its SemVer evidence are recorded in the migration inventory. The owner explicitly confirmed retaining the v13 release train on September 20.
This queue deliberately builds on existing code instead of restarting those capabilities. Within P1, a small independent increment can precede a larger one when its dependencies and verification capacity are ready.
| Roadmap area | Starting point and next bounded increment | Scheduling boundary |
|---|---|---|
| M41 — privacy and age | Hosted admission includes proportional policy, declarations, signed attestations, SQLite/PostgreSQL replay adapters, optional processing withdrawal and a scoped own-account profile export in generated consumers. | Preserve those admitted journeys in the final campaign. Initial package registration remains outstanding; live age providers and facial models remain outside the delivered scope. |
| M11/M33 — SaaS entitlements | The typed current-state Capital gate and generated SaaS enforcement journey were admitted with PR #221. | Preserve current tenant/owner, plan and subscription checks during combined validation; the broader billing roadmap is not complete. |
| M5/M29/M34 — API/SDK contracts | The bounded schema-first Rust/TypeScript API profile and real HTTP consumer were admitted with PR #221. | Retain schema/transport/ownership regression coverage. React, Dart and Swift targets remain follow-up work; route scanning does not supply typed response semantics. |
| M21 — Omni/Android | CLI verification of the exact signed APK and configured certificate was admitted with PR #220 after hosted Android SDK acceptance. | Retired in 13.0.0-alpha.3 with Omni: make:pwa replaces the shells, and its Google Play path (Bubblewrap and Digital Asset Links) has no framework-built APK to verify. Physical-device and store acceptance remain unvalidated. |
| M9 — Auth/session consistency | PR #223 admitted optional PostgreSQL passkey ceremonies with tenant/account/session/RP binding, bounded single use, real database/process recovery and a Chromium virtual authenticator. | Preserve credential-owner/revocation/counter CAS at the host and repeat affected combined-release checks. See the contract. |
| Transparent supervision | The baseline passed hosted and archive acceptance in PR #222. PR #226 merged the prioritized exam-platform extension after hosted checks; its missing archive gate subsequently passed in the exact-commit run recorded above. The generated LMS journey (make:supervision) was later removed with the complete Academy scaffold; the crate’s contracts remain. | Preserve transparent permissions, typed uncertain observations, manual review and no raw media retention in the final campaign. See the integration boundary. |
| M27 — deployment acceptance with an existing proxy | PR #224 admitted the response-body lifetime correction and the real two-process Caddy/Redis contract for readiness, draining, shared budgets/outage, forwarding, CSRF/body limits and WebSocket behavior. | Preserve combined-release coverage. This loopback fixture does not establish a multi-replica deployment (the Foundry generator was removed in 13.0.0-alpha.4), cross-host failover or zero downtime. See deployment acceptance. |
| M10/M27 — cloud and VPS application protection | PR #225 merged the offline deploy:doctor with explicit environment sources, bounded inputs, redacted reports and rejection of the public Auth key placeholder after hosted checks; its missing archive gate subsequently passed in the exact-commit run recorded above. | Preserve the installed-archive and diagnostic coverage in the final campaign and prepare the separate stable Auth backport. No automatic host/cloud changes or volumetric DDoS guarantee. See the diagnostic and deployment boundary. |
| LMS/Academy — managed private video | PR #227 admitted the unpublished rullst-media candidate after hosted workspace/platform, browser and installed-archive acceptance. It implements Bunny lifecycle management, resumable upload, authoritative processing, private playback and deletion with SQLite recovery. | Preserve the supported journey in the final combined release campaign. Live-account interoperability stays unvalidated. The package has since joined the release inventory (.github/release-order.json) and was first published in the 13.0.0-alpha.1 prerelease; final release admission remains separate. See the managed-video candidate. |
| M40 — Labs | The rullst-labs candidate, first published in the 13.0.0-alpha.1 prerelease, provides encrypted durable exercises/jobs, exact grading and cancellation/recovery/retention. The former rullst-labs-runner candidate, a separate Linux Rust/Wasmi executor, passed 26 ordinary and 26 instrumented hosted journey checks at 977e40a3. On 30 September 2026 the maintainer removed it from the 13.0 workspace; its source remains in git history. rullst-labs is now bring-your-own-runner, with a documented controller contract and a non-executing example controller. | PR #228 passed workspace/platform and actual extracted-package source admission, plus both 90% coverage floors; that historical evidence covered the removed runner. Final release admission of rullst-labs remains outstanding, and each application-owned runner needs its own isolation review. See the recorded profile evidence. |
| M15 — remote messaging | PR #236 admitted the optional standalone Redis Streams profile with TLS, restart/redelivery, fenced leases, exact replay, retry/DLQ and outbox evidence. | Preserve the documented Redis and operator boundaries in combined validation. Other broker adapters, native Redis group interoperability and replication/failover remain separate roadmap work. |
| M39 — optional Rullst Gateway | No rullst-gateway crate or executable exists. On 8 October 2026 the maintainer abandoned it in favour of generated Caddy/nginx configuration for established proxies; see the master roadmap. | No delivery. Proxy work continues as generated configuration (Caddy today, nginx planned) consuming the existing readiness/drain signals. |
| M1/M3/M7/M12 — adoption and assurance | The compatible updater is carried forward. cargo rullst upgrade reports v12 → v13 source findings from rullst-upgrade-rules-v4, each tied to a migration-guide row. Next: improve generated guidance and connect new code to the relevant verification inventory. | Required adoption/security work plus bounded maintainer tooling; Verus begins with one production-linked pilot. |
Gateway/load-balancer implementation, physical IoT/Embassy, PQC protocols, local facial inference and broad database replication retain their dedicated roadmap scope. They are not counted as completed by a proposal, an empty crate or a mock. Reconsider them through their own dependency and acceptance evidence instead of promising the whole long-term programme by the 26th.
At each implementation checkpoint, record the delivered behavior, source, focused checks, pending hosted checks and next useful increment. The decision to start the next item uses remaining integration/test capacity as well as coding time. Preserve working increments and report unfinished acceptance explicitly; do not inflate a feature count by splitting contracts into crates.
Age and privacy design decisions to close first
Keep age assurance and reusable privacy contracts in the existing optional
rullst-privacy package. Do not add a second age crate or make cameras, a vision
runtime, a database or a provider a default Core dependency.
The v13 ReplayStore::claim interface now returns a statically
dispatched Send future. Verification samples a trusted clock before validation
and after storage, denying expiry or rollback during the wait. The initial
SQLite adapter uses serialized durable transactions, persisted quota/clock
metadata and hashed nonces. Local tests cover independent verifier pools, reopen,
simultaneous claims, capacity, expiry, cancellation, uncertain acknowledgement
and unavailable storage. Native hosted evidence remains pending. Its
shared-local boundary is distinct from the PostgreSQL adapter’s multi-host operation;
restoring an old backup requires quiescing verification and invalidating all
outstanding challenges through a newly enforced policy or retired signing keys.
Use the current server-owned policy and signed threshold result rather than storing photos or full birth dates in the framework. Provider authenticity, subject/session binding and the quality of an age determination are separate contracts. A facial estimate cannot silently become a verified age attribute. Every denied, inconclusive or unavailable result needs an explicit product outcome; an alternative path must meet the same policy instead of bypassing it.
A live adapter requires an available authorized sandbox, a reviewed native protocol, failure tests and appropriate provider/capture evidence. The release owner confirmed that no provider environment is available and directed work on the foundation and local tests first. Prioritize that independently testable scope while the external dependency is unresolved. If it remains unavailable, do not label a simulated flow as live verification or mark the provider milestone complete. The approved independent foundation has its own consumer/state/API acceptance; any live adapter additionally needs its provider acceptance. Review this precise publishable package boundary at feature freeze, preserving the security guarantees of every advertised method.
For broader privacy, implement reusable processing controls and one real consumer journey before claiming coverage across every blueprint. Keep policy applicability, notices, processor obligations and external legal review explicit. The privacy roadmap remains authoritative for the wider jurisdiction and provider programme; this plan does not certify GDPR, LGPD or worldwide compliance.
Improvements that reduce maintenance friction
- A current source baseline. Synchronize stable fixes before feature work; avoid repeatedly rediscovering or reintroducing already repaired SaaS, mail and updater problems.
- One executable test map. Preserve complete test inventories and choose focused development checks from actual crate/dependent changes. Unknown, security, generator, dependency and workflow changes retain conservative coverage. Measure job execution separately from runner queue time.
- Accurate generated guidance. Derive agent instructions and context from the chosen blueprint/features. Link to version-matched public APIs and commands; do not include credentials or imply that optional adapters are active.
- Small complete examples. Use reproducible SaaS/LMS reference journeys with negative ownership, replay, withdrawal and recovery assertions. A compiling tutorial alone does not establish those behaviors.
- A durable progress record. Record the source, checks, limitations and next action for each delivered item. Avoid duplicated readiness statements that drift apart across the README, roadmap, spec and release guide.
These implement the narrow first phase of the AI-maintainability roadmap. Broad model benchmarks and quality guarantees about arbitrary assistants are outside this release window. Keep changes modular; avoid unrelated mass refactoring merely to meet a line-count target.
Calendar and scope control
Superseded deadline: the owner revised this calendar on September 22. September 26 is no longer a required publication date. Use the checkpoints below as the original sequencing record; prioritize stable maintenance and completed, tested journeys under the revised plan.
Dates below use Brasília time. Work may move earlier when its dependencies and checks are ready; slow external acceptance reduces scope, never test quality.
The owner reconfirmed this calendar on September 20: prioritize remaining implementation through September 23 by user benefit, maintenance cost and acceptance capacity; reserve September 24–25 for validation/corrections and September 26 for final adjustments and protected publication. Local deployment configuration diagnostics and the supervision observation extension are merged with hosted checks passed. Their archive gaps have now been repaired as recorded above. The owner’s subsequent direction selects the complete supported Bunny journey next, followed by usable Labs execution and grading, before remote messaging or other optional expansion. Bunny has passed source admission; Labs has now passed source admission with its remaining quality, independent-review and release requirements tracked above. Increasing the crate count is not a priority.
The owner subsequently requested no manual or real-provider account testing in this window, including the confirmed existing Bunny library. Continue automated local/hosted tests, disposable databases and protocol fixtures. Preserve required regression/security gates and report unvalidated provider interoperability explicitly; do not describe simulated acceptance as a live integration pass. New optional provider scope can be delivered with that stated limitation, rather than waiting for manual tests or using the owner’s live resources.
| Date | Checkpoint |
|---|---|
| 20 September | Close v12.1 documentation, admit the integration baseline, settle privacy/storage API decisions and prepare the v13 release/branch policy. |
| 21–22 September | Admit and repair the existing candidates; prioritize completing the Bunny lifecycle, then the Labs execution/grading journey as dependencies and verification capacity permit. Maintain migration fixtures and automated acceptance with each API change. Assess provider scope by the end of the 22nd using automated protocol evidence, without live-account testing. |
| 23 September | Close supported product journeys, recovery paths, generated guidance and documentation. Resolve the Bunny/Labs acceptance boundaries before considering another optional implementation. Freeze feature scope by the evening. |
| 24 September | Run the complete candidate verification campaign, including required native matrices, fuzzing, Miri, Kani and sanitizers. Preserve explicit evidence boundaries and start long jobs early. |
| 25 September | Repair findings, invalidate and repeat affected evidence, test packaged consumers and rehearse the complete publication transaction. |
| 26 September | Buffer for final failures, review and protected publication. Publish stable only with all mandatory criteria satisfied; otherwise report the exact blockers and an honestly scoped candidate. |
At the feature freeze, move unfinished optional work to a named follow-up rather than leaving partially enabled behavior in a stable release. A blocking age, authorization, data-loss or migration defect is not an optional follow-up. Keep approved feature scope in the release notes and tests; do not mark the historical roadmap complete merely because the version is published.
Verification and final publication
Run cargo test --workspace --all-features, strict workspace/all-feature
Clippy and formatting, plus the existing feature, threat, provider-protocol,
generated-application, native-platform and release gates. New privacy parsers,
state transitions and durable adapters need their own meaningful negative,
concurrency and fuzz/property coverage. Local focused passes never substitute
for the final workspace campaign.
Use hosted CI for builds whose peak cannot fit the workstation’s reserve; retain at least 12 GiB available and intervene at 15 GiB. Reuse verification only through the existing explicit input-equivalence/admission policy. Changes to a candidate invalidate the affected evidence; neither a green older commit nor cached compilation is a release result.
Before tagging, check every package/internal requirement, lockfile, release
order, architecture edge, feature forwarding, CLI version string, documentation
link and supported-platform claim. rullst-privacy, rullst-supervision,
rullst-media and rullst-labs were first published in the 13.0.0-alpha.1
prerelease (see v13 package registration);
their final release admission remains outstanding. Confirm 12.x→13 consumer
behavior, authenticated native assets, attestations and the protected
deployment approval. Published tags and archives remain immutable.
Execution proceeds within the active authorized session, while GitHub jobs can continue independently. Session availability and a running CI job are different things; this plan does not promise an unattended uninterrupted week. At each handoff, retain the next action and distinguish work still possible from work actually waiting on an external result.
Sustainable v12 maintenance and v13 priorities
Status on 29 October 2026: v12 reached end of life on 16 October 2026;
12.3.0 is its last release and the v12 branch is frozen (see
SECURITY.md). 13.0.0-alpha.4 is the 13.0 feature freeze,
followed by 13.0.0-rc.1 (fixes and documentation only) and then 13.0.0. The
September directions below record how the priorities were set; the v12
maintenance instructions in them no longer apply.
Direction revised on 22 September 2026
The owner prioritizes a maintained, secure stable v12.1 line and useful v13 deliveries over publishing a major version merely to meet September 26. Work may continue in later funded sessions. September 26 is a handoff/review point, not a mandatory release date. An alpha is an optional evaluation milestone, not a substitute for safety, packaging or publication checks.
Keep main for v13 development and v12 for stable maintenance. Compatible
bug/security/dependency fixes can become 12.1.x after validation; new compatible
public functionality may require a minor release, and breaking changes remain
in the major development line. A dependency’s SemVer number alone does not
prove compatibility. Preserve the supported compiler, features, serialized
contracts and generated-project behavior. Do not backport v13 wholesale.
This document proposes investment priorities, not approval to implement every row, publish, expand supported platforms or claim a permanent LTS programme. Actual release status and support remain governed by SECURITY.md and the compatibility policy.
The owner subsequently adopted the crate maintenance and extension strategy on September 22. It turns the cost assessment into a direction for scoped investment: preserve common security/productivity contracts, contain Capital’s provider and fiscal scope, and give specialized extensions explicit ownership and migration boundaries. It does not remove supported capabilities or change the v12.1.1 candidate. Specific restructuring remains follow-up work.
Counting method
The canonical roadmap contains 41 top-level milestones, M1–M41. Their detailed per-crate roadmaps overlap and decompose these themes; adding every checkbox would double-count work. This is a prioritization of those 41 themes, not a fresh audit of every source file or a claim that all of them were committed for 13.0.0.
| Proposed category | Themes | Existing roadmap labels |
|---|---|---|
| A — invest and maintain | 25 | 5 bounded implemented; 20 partial |
| B — defer expansion until justified | 16 | 7 partial; 9 not implemented |
| Total | 41 | 5 bounded implemented; 27 partial; 9 not implemented |
Use a separate C — high maintenance cost marker across either category. The initial review flags 12 existing implementation themes below (11 in A and 1 in B). C overlaps A/B; it is not another twelve items and does not mean the feature should be removed. Cost assessments are architectural estimates, not measured monthly spending or guarantees about future contributor availability.
M31 is a separately governed aerospace/autonomous/defence programme. Excluding it gives 40 framework themes: 25 A and 15 B, matching the master roadmap’s 40-theme horizon. A partial theme may include substantial working software and years of optional extensions. Counts do not measure effort or completion percentage. Supervision is cross-referenced under the education/privacy themes; Media belongs to M17. They are not added again as duplicate milestones.
The assignment concerns the next investment. Existing supported functionality still receives defect/security maintenance even when its expansion is in B. For broad A themes, only the bounded scope below has priority; selecting a row does not select every research idea in its original wording.
A — invest and maintain
| ID | Priority scope | Why it is worth the maintenance cost |
|---|---|---|
| M1 | CLI and supported generator/blueprint combinations | A broken generated application prevents adoption; preserve real compilation and behavior checks. |
| M2 | Measured build/reload improvements | Reduce repeated developer/CI cost; keep supervised restart and defer a stable dynamic Rust ABI. |
| M3 | Feature boundaries, escape hatches and migrations | Keep upgrades reversible and optional capabilities out of small applications. |
| M4 | Resource generator and local error console | Maintain the bounded existing implementation; autonomous modification belongs to deferred M37. |
| M5 | Navigable, executable documentation and typed API contracts | Users need accurate examples and errors more than additional undocumented surfaces. |
| M6 | Reliable relational ORM, transactions and supported adapters | Prioritize data integrity and recovery; do not bundle transparent replication into this scope. |
| M9 | Sessions, account recovery, passkey assurance and revocation | Authentication failures directly affect users; close named security/conformance gaps before enlarging identity scope. |
| M10 | Validation, bounded requests, mail and shared rate limits | Improve common SaaS paths with reusable contracts and explicit backend failures. |
| M11 | SaaS administration and supported billing journeys | Stabilize actual Academy/SaaS needs; do not equate this with universal gateways or the whole Omni vision. |
| M12 | Security, tenant isolation, secrets and supply-chain assurance | Continuous prevention and repair have broad value; additional tools need a concrete uncovered risk. |
| M14 | Accessible HTMX/SSR and clear frontend interoperability | Improve the primary supported experience before full Leptos/Dioxus integration. |
| M15 | Durable jobs, scheduling and one supported remote broker | Finish recovery, retries, quotas and operations before adding a broker catalogue. |
| M17 | Private files, resumable upload and course video | Directly serves SaaS/Academy; retain access, expiry and recovery checks. A package marketplace is not part of this priority. |
| M18 | Recoverable server-driven UI | Maintain the admitted authorization/reconnect journey before adding a new rendering ecosystem. |
| M19 | Honest runtime telemetry and standard exports | Preserve implemented diagnostics; unavailable sources must not become fabricated values. |
| M26 | Safe deployment guidance and read-only diagnostics | Help operators find configuration mistakes and rehearse rollback without automatic infrastructure mutation. |
| M27 | Readiness, draining and deployment scaffolds | Maintain the bounded implementation and actual proxy/application contracts. |
| M28 | Existing typed dependency injection | Keep its API small and tested; no new DI framework is necessary for release. |
| M29 | Accurate OpenAPI and its playground | Prefer schema/transport fidelity over adding UI controls around incomplete contracts. |
| M32 | Axum/Tower interoperability and precise macro errors | Preserve existing escape hatches and diagnosability. |
| M33 | Current-state SaaS entitlements | Access must reflect current account, tenant, plan and payment state; syntactic convenience comes later. |
| M34 | One dependable TypeScript SDK profile | Maintain serialization and migration evidence; additional React/Dart/Swift targets require users and tests. |
| M35 | Standard trace propagation/export and useful diagnosis | Build on existing OpenTelemetry collectors; a custom durable observability service is not a prerequisite. |
| M40 | One bounded Academy Labs profile | Product value is concrete, but untrusted execution needs independent isolation review. The runner candidate was removed from 13.0; applications bring their own runner through the documented contract. Labs need not block the rest of v13. |
| M41 | Practical privacy and proportional age policies | Preserve minimal data, explicit decisions, consent and rights workflows. Supervision remains transparent and scoped; declarations/signals do not prove age, guardianship or cheating. |
M4, M19, M27, M28 and M32 are the five already-implemented labels. The other twenty A themes need scoped decisions, maintenance or further work; none is a blank cheque to finish all of its historical ambitions.
Within A, use this order: security/data-loss and stable regressions first; authentication, tenant boundaries and billing correctness next; migrations, recovery and usable documentation next; then product expansion supported by Academy/SaaS demand. Run one bounded feature effort at a time alongside the stable maintenance lane. Reassess after each completed journey.
The Nexus application integration plan refines M11 with host-session authorization, read-only capabilities, strict-CSP assets, localization and explicit data ownership. It also identifies a separate M32 boolean-attribute ergonomics proposal. These are scoped follow-ups within the existing counts, not delivered APIs or additional top-level milestones.
B — defer expansion until justified
| ID | Deferred expansion | What would justify revisiting it |
|---|---|---|
| M7 | Portable edge runtime and distributed-data expansion | A named deployment target and application need, with vendor-specific consistency and rollback evidence. |
| M8 | Intent-based modelling and index recommendations | A measured database problem; begin with explainable, reviewed advice, never unattended production DDL. |
| M13 | Post-quantum web architecture and plugin containment | A concrete protocol/threat model, maintained audited primitives and an interoperability programme. |
| M16 | Wasm islands and broad hydration/component packaging | A user journey that SSR/HTMX cannot reasonably meet and a supported browser/toolchain matrix. |
| M20 | New immutable ledger/event-streaming engine | A consistency/recovery requirement unmet by established storage, plus a funded maintenance owner. |
| M21 | Broader Omni/mobile/offline platform | Omni was retired in 13.0.0-alpha.3 in favour of installable PWAs (make:pwa, Google Play TWA, Microsoft Store via PWABuilder). Native capabilities need a real mobile product with physical-device, secure-key, networking and store acceptance resources. |
| M22 | Agentic infrastructure provisioning | A named operator need and reviewed plans, scoped credentials, audit and rollback; advisory diagnostics already fit A. |
| M23 | Auto-healing production code or databases | A safely bounded, approved repair contract; no general autonomous mutation promise. |
| M24 | Additional IoT transport/firmware/hardware integrations | Selected boards, actual devices and transport/rollback interoperability tests. Preserve existing no_std and manifest contracts. |
| M25 | Embassy integration | Stable hardware/transport boundaries and a maintained target application. |
| M30 | Dedicated gRPC crate and broader scaffolding | A concrete service consumer and protobuf compatibility matrix. The make:grpc generator was removed in 13.0.0-alpha.4. |
| M31 | Aerospace, autonomous vehicles and defence | A separate project with domain expertise, governance, hardware and applicable certification. |
| M36 | Natural-language database copilot | Concrete operator demand and a read-only, bounded, explainable contract; production writes remain separate. |
| M37 | AI autofix workflow | A confined local patch/review/test/rollback journey with demonstrated value over existing development tools. |
| M38 | Generic local read replicas and background replication | A named database’s supported semantics and recovery guarantees; no generic transparent-replication claim. |
| M39 | Rullst’s own gateway/load balancer | Abandoned on 8 October 2026: generated configuration for established proxies (Caddy today, nginx planned) replaces it. Revisiting it would need a new maintainer decision. |
“Deferred” means lower expected return under the current product, budget and validation constraints, not that the idea is intrinsically bad. An explicit user requirement can change this ranking. Unsupported guarantees such as automatic worldwide legal compliance, zero leakage or general autonomous safe mutation are not future deliverables to promise at any budget.
C — high maintenance cost, including existing implementations
The adopted crate-level decisions refine this theme-level assessment without changing its counts. Their transition sequence governs future scope reductions and extractions.
The source already contains at least a bounded implementation in each theme below. “Existing” includes v13 prerelease code and does not imply a complete milestone or a stable crates.io release. The primary repeated costs are provider drift, supported combinations, security review and recovery evidence, not merely writing the first implementation.
| Theme | Existing surface requiring care | Recurring cost and how to contain it |
|---|---|---|
| M1 — CLI/scaffolds | Three starters (Blank, API and SaaS), database selections and installed generators | Changes multiply into generated applications. Maintain a declared supported matrix and shared templates; add combinations only for a user need. |
| M2 — build/reload | Cross-platform process supervision, filesystem behavior and compiler/toolchain integration | OS-specific regressions and repeated native compilation. Preserve reproducible targeted tests and measure build versus queue time. |
| M6 — persistence | Relational and heterogeneous database/native adapters | Different transactions, types, consistency and native libraries. Keep adapter boundaries explicit and avoid universal parity promises. |
| M9 — authentication | Passwords, sessions, recovery, tokens and passkey state | Security-sensitive lifecycle, standards and negative/concurrent cases. Prefer maintained primitives and one coherent supported identity path. |
| M10 — mail/protection | Multiple external transports, provider feedback and shared limits | Provider schemas, auth, quotas and delivery behavior change. Use common contracts while retaining provider-specific assertions and capability limits. |
| M11 — payments/SaaS | Multiple payment adapters, signed webhooks, subscriptions and entitlements | Remote semantics, API changes, retries and money/state reconciliation. Prioritize used methods and never claim untested uniform gateway coverage. |
| M12 — security/assurance | WAF/DLP, tenant policy, secrets, audits and verification tooling | Threat/advisory changes and tooling/fixture upkeep. Keep a risk-to-test map and prevent duplicated or unbounded campaigns. |
| M15 — async delivery | Local/durable queues, retry/leases/outbox and Redis profile | Failure ordering, replay, shutdown and backend recovery. Finish one durable journey before broadening brokers. |
| M17 — files/video | Private storage, multipart and Bunny browser/provider lifecycle | Upload interruption, signing/expiry, notifications, SDK and browser changes. Bound supported protocols and separate fixture evidence from live interoperability. |
| M21 — native delivery | Installable PWA scaffolding (make:pwa) and its store-packaging guidance; the Omni desktop/mobile shells and Android artifact verification were retired in 13.0.0-alpha.3 | Browser, service-worker and store-packaging tool changes. Maintain current guarantees but defer native device/offline promises without test resources. |
| M40 — Labs | Trusted orchestration and the bring-your-own-runner contract | Contract stability, leases and grading. Execution, OS/toolchain changes and isolation review belong to the application-owned runner; keep one narrow profile and never trade containment for convenience. |
| M41 — privacy/supervision | Age/consent/replay and transparent observation contracts | Sensitive state, retention, authorization and evolving product/legal requirements. Minimize data and keep application/jurisdiction obligations explicit; no automatic legal certification. |
PQC, embedded hardware, autonomous production changes, generic replication and a new gateway also have potentially high future maintenance costs. Their B status means those costs are not accepted merely by keeping the ideas on a list. Other themes still need maintenance; omission from C does not label them free or low-risk.
Before enlarging a C surface, record a named product need, supported boundaries, repeatable acceptance, a person/team responsible for reviewing incidents and updates, and a fallback if that support is unavailable. Prefer freezing new scope or deprecating with migration notice over silently abandoning a supported capability. A feature’s past implementation cost is not a reason to keep adding expensive options without users.
Eight concrete follow-up suggestions
These are proposed subtasks, several already implicit in the detailed roadmaps. They do not create eight additional top-level milestones and are not automatically authorized implementation work.
| Proposal | Category and parent | Bounded useful result |
|---|---|---|
| S1 — downstream regression fixtures | A; M1/M3/M11 | Compile and exercise sanitized SaaS/Academy fixtures against a candidate release, including upgrades and denied access, without production data or provider accounts. |
| S2 — application recovery rehearsal | A; M6/M11/M15 | Restore a disposable application snapshot and verify accounts, tenant isolation, durable jobs and billing idempotency. Document quiescence, keys and restore order; this does not become a managed backup service. |
| S3 — reusable authorization test scenarios | A; M9/M12 | Offer a small application-test recipe or helper for anonymous/user/admin and two-tenant negative cases, using actual routes. Do not treat a static scanner as proof. |
| S4 — resumable maintenance handoff | A; M1/M3/M12 | Keep a concise source-bound receipt of supported versions, unresolved findings, exact CI evidence, next commands and session/build cost. Reuse existing reports so later, less frequent sessions need not repeat the investigation. |
| S5 — first-party facial age engine | B; M41 | Consider only with a suitable dataset, accuracy/bias and attack evaluation, capture/privacy controls, alternative paths and a long-term maintainer. Continue policy/attestation support independently. |
| S6 — additional Labs languages | B; M40 | Add one demanded protocol profile only after a reviewed application-owned runner proves the first profile operationally, with separate toolchain/resource/threat tests. |
| S7 — accessible, localized primary journeys | A; M5/M14 | Exercise keyboard focus, form errors, captions and translation/locale boundaries on the chosen auth, checkout and learning journeys. Reuse the existing LMS groundwork; automated checks do not establish universal accessibility or translation quality. |
| S8 — per-tenant resource budgets | A; M10/M11/M15 | Define explicit quotas/backpressure for stored bytes, concurrent jobs, mail and AI usage, with atomic claims and visible rejection. Extend existing bounded contracts only where an application gap is demonstrated; no automatic provider-bill guarantee. |
S4 and stable defect/compatibility maintenance are the immediate cost-control priorities. S1–S3 should be selected by an observed gap, not implemented as duplicate test frameworks. S5 and S6 illustrate valuable possibilities with substantial continuing costs; neither belongs on the release critical path. S7/S8 can add practical user value without another standalone subsystem, but must have a bounded accepted contract before implementation. These proposals comprise six A subtasks and two B subtasks. S5/S6 would also carry C; S1/S2/S8 can become expensive if allowed to grow into an unbounded platform matrix.
Do not report “49 independent milestones”: the eight proposals refine the existing 41. Promote a truly distinct need to a new canonical milestone only after checking overlap and documenting who will use and maintain it. A richer framework delivers reliable useful journeys; raw crate or checkbox counts are not the success criterion.
Comparative evaluation adopted on 23 September
Use the framework reference and evaluation plan to study Loco/Laravel productivity, Django security/documentation and Axum composition, with other frameworks as targeted references. Measure development effort, failures, performance and maintenance on equivalent application journeys. This refines existing A themes and S1-S3; it does not add milestones or change the 25/16 counts.
The 25 A themes are not prerequisites for the first comparison. After the
v12.1.1 maintenance work, start with a small usable SaaS journey, establish a
baseline and use the findings to choose subsequent improvements. The first
Rullst-side acceptance contract is the opt-in cli-saas-journey diagnostic
described in that plan. Broader evaluation can accompany later v13
checkpoints. Keep the same acceptance contracts, record unfavorable and
inconclusive results, and distinguish AI-assisted measurements from
independent user evidence. Only free resources and disposable offline fixtures
are authorized; no superiority is established by this planning decision or the
internal quality scorecard.
What makes v13 ready
Do not wait for all 41 themes, all 25 A themes or a percentage of this list. Choose a bounded release contract, document intentional breaking changes and prove its supported user journeys, migration/recovery, security, packaging and protected publication. Existing admitted work should be completed and hardened before unrelated expansion. Any deferred capability stays disabled or clearly scoped; a known security or data-loss defect is not an optional future feature.
The 13.0.0-alpha.N prereleases collect explicit evaluation feedback; with v12
at end of life, the latest v13 prerelease is the supported line until 13.0.0.
13.0.0-alpha.4 is the feature freeze, 13.0.0-rc.1 takes only fixes and
documentation, and 13.0.0 follows. A prerelease label does not resolve
independent-review or safety blockers. Further compatible capabilities can ship
in 13.1 and later minors; unfinished roadmap ideas do not all require v14.
Before an extended pause, leave stable maintenance and candidate code with their actual evidence, pending jobs and next actions recorded. Prefer a small recoverable checkpoint over another half-finished subsystem. This approach supports intermittent work without promising continuous model availability or unfunded indefinite maintenance.
v13 maintenance scope and extension strategy
Decision adopted on 22 September 2026: concentrate Rullst’s investment on secure, productive application development and keep specialized capabilities within explicit maintenance boundaries. Reduce scope before removing useful software. Code generation by an AI assistant does not remove the need for maintenance, integration evidence or security review.
This is an approved planning direction, not an implemented package split or a deprecation notice. It leaves the published v12.1.1 release, supported APIs, defaults, MSRV and current publication inventory unchanged. Specific extractions, provider selections and retirements need their own reviewed changes. The SST describes the current architecture; the compatibility policy governs transitions.
Direction reaffirmed on 24 September 2026: prioritize measured maintenance cost and coherent application journeys before expanding the adapter catalogue. The next planning deliverable is a decision record per integration, beginning with Capital, specialized database adapters and the Labs runner. Retain useful maintained implementations; an interface without a usable implementation can transfer both development work and security mistakes to every application.
This direction can improve the conditions for broad adoption by concentrating effort on reliability, predictable upgrades and developer experience. It is not evidence of a global framework ranking. Use the comparative evaluation plan, independent feedback and application adoption to assess progress. Feature count and mutation scores alone do not establish superiority.
How to assess maintenance cost
The assessments below are qualitative, not measured engineering hours or monthly costs. They include v13 prerelease candidates. Review each capability using its actual user journey and these recurring obligations:
- external API, protocol, operating-system and toolchain changes;
- supported database, platform, blueprint and feature combinations;
- consequences of security, data-integrity and recovery failures;
- dependency updates, diagnostics, migrations and documentation;
- repeatable acceptance and any outstanding provider or independent review;
- a named person or team able to review incidents and maintain the contract.
Source size and crate count alone are poor cost measures. A small untrusted-code runner can require more operational assurance than a larger template library. An optional dependency reduces the consumer’s selected build surface; it does not eliminate the project’s support obligations. Moving code to another repository saves work only when scope, ownership or release coupling changes.
For each integration, record its actual consumer, maintained operations, dependency/API versions, compatibility matrix, validation gaps and accountable maintainer. Measure update/review effort, regressions and CI execution separately from queue time; leave unknown costs explicitly unknown. Recommend one of:
- retained official support with a bounded expansion scope;
- specialized optional extension with an explicit maintenance/release owner;
- an application/community integration with a tested contract and transition;
- deprecation with a replacement and compatibility plan, when justified.
Provider release frequency alone does not decide the category: a compatible upstream release need not require an adapter rewrite, while a rarely changing executor can still carry substantial security obligations. Evaluate independent adapter releases where stable interfaces and dependency ranges allow them; a provider correction should not unnecessarily force unrelated framework releases. This requires an actual packaging, compatibility and release-workflow migration, not just a new repository name. Security support for shipped capabilities remains in force throughout that transition.
Measured cost snapshot (13.0.0-alpha.3)
Measured on 29 October 2026 at the main that follows the 13.0.0-alpha.3
publication. These are proxies for recurring work, not hours: each line of
production code, each mutant and each fuzz target is something every release
must keep compiling, tested and admitted. Hosted CI minutes per crate and the
time spent on reviews and incidents were not measured; they stay unknown.
| Package | Tier | Production lines | Test lines | Tests | Mutants | Fuzz targets | Dependencies | Commits (60 days) |
|---|---|---|---|---|---|---|---|---|
cargo-rullst | Core | 66,544 | 36,138 | 1,212 | 9,454 | 0 | 307 | 520 |
rullst-core | Core | 41,987 | 34,002 | 1,193 | 5,494 | 0 | 287 | 398 |
rullst-orm | Core | 21,871 | 26,402 | 487 | 2,414 | 5 | 304 | 258 |
rullst-security | Core | 12,178 | 7,426 | 404 | 1,842 | 7 | 248 | 119 |
rullst-orm-macros | Core | 11,504 | 2,376 | 138 | 542 | 0 | 4 | 133 |
rullst-macros | Core | 3,084 | 2,165 | 77 | 0¹ | 0 | 11 | 36 |
rullst (facade) | Core | 560 | 4,733 | 133 | 10 | 12 | 525 | 174 |
rullst-connect | Extension | 13,796 | 12,484 | 391 | 1,200 | 3 | 242 | 88 |
rullst-ai | Extension | 13,529 | 6,068 | 232 | 2,131 | 3 | 231 | 72 |
rullst-studio | Extension | 11,893 | 5,637 | 157 | 17¹ | 1 | 302 | 103 |
rullst-mail | Extension | 11,750 | 8,347 | 272 | 1,735 | 4 | 306 | 131 |
rullst-capital | Extension | 11,157 | 6,342 | 214 | 1,782 | 1 | 311 | 140 |
rullst-auth | Extension | 10,236 | 12,251 | 244 | 1,818 | 0 | 277 | 72 |
rullst-messaging | Extension | 8,830 | 9,156 | 176 | 1,576 | 0 | 205 | 57 |
rullst-nexus | Extension | 7,422 | 9,455 | 239 | 920 | 1 | 326 | 135 |
rullst-media | Experimental | 4,397 | 3,882 | 60 | 874 | 0 | 160 | 28 |
rullst-labs | Experimental | 3,785 | 3,274 | 78 | 558 | 0 | 110 | 43 |
rullst-privacy | Experimental | 3,482 | 4,392 | 102 | 562 | 2 | 134 | 35 |
rullst-supervision | Experimental | 3,113 | 3,769 | 66 | 598 | 0 | 95 | 24 |
rullst-iot | Experimental | 2,593 | 1,395 | 83 | 307 | 3 | 34 | 37 |
¹ Most of the crate is excluded from mutation with mutants::skip, so the
listed count understates its test obligation.
By tier, the share of the workspace totals (263,711 production lines, 5,958 tests, 33,834 mutants, 42 fuzz targets):
| Tier | Production lines | Tests | Mutants | Fuzz targets | Commits (60 days) |
|---|---|---|---|---|---|
| Core | 60% | 61% | 58% | 57% | 63% |
| Extension | 34% | 32% | 33% | 31% | 31% |
| Experimental | 7% | 7% | 9% | 12% | 6% |
What the numbers show:
- The CLI is the largest single cost.
cargo-rullstalone holds a quarter of the production code and 28% of the mutants, and changes most often, because every generator multiplies database, blueprint and platform combinations. Reducing generator combinations saves more than retiring any optional crate. - The experimental packages are a small share. Together they are about 7% of the code and 9% of the mutants. Narrowing their promise lowers support obligations more than CI work; retiring them would not change the release cost much.
- External services are concentrated in four Extensions (
rullst-connect,rullst-mail,rullst-capital,rullst-ai): about 19% of the code, and the part whose correctness depends on providers changing their APIs. Each new provider adds to this group. - Two production applications (October 2026) enable the facade with
orm,strict-postgres,nexusandauth, plusrullst-connect. The packages neither application enables directly (messaging, AI, Capital, mail, Studio and the five experimental packages) are about 28% of the code. That is evidence of current use, not a reason to retire a package by itself.
How the columns were measured: Rust lines under each package, excluding
target/ and fuzz/ (test lines are tests/, benches/, *_tests.rs and
tests.rs); tests are #[test], #[tokio::test] and #[rullst_orm::test]
attributes; mutants are cargo mutants --list -p <package> --all-features;
dependencies are the unique packages in cargo tree -p <package> --all-features -e normal, without the package itself; fuzz targets are the
[[bin]] entries of <package>/fuzz/Cargo.toml; commits are
git log --since=60.days -- <package>.
Crate-level investment decisions
| Crate or area | Maintenance assessment | Adopted direction |
|---|---|---|
rullst-core, rullst, rullst-macros | Essential shared surface with substantial compatibility and security impact. | Maintain explicit HTTP/lifecycle contracts, safe defaults, useful diagnostics and Axum/Tower interoperability. Keep specialized domains optional and avoid expanding the kernel into a general infrastructure platform. |
cargo-rullst | High: generated applications multiply database, blueprint, platform and installer combinations. | Retain as a central productivity investment. Share templates, declare the supported matrix and validate complete generated journeys. Defer additional combinations until there is demand and acceptance capacity. |
rullst-orm, rullst-orm-macros | High: transactions, schema changes, database differences and native dependencies. | Prioritize dependable relational persistence and migrations. Treat specialized stores as explicit adapters; do not promise universal parity or generic replication. |
rullst-auth, rullst-security, rullst-connect | High and necessary: failures affect application identity, secrets and tenant boundaries. | Maintain narrow, coherent contracts using established primitives. Reduce duplicated responsibilities and close documented gaps before expanding providers or security-product ambitions. |
rullst-messaging | High: leases, retries, duplicates, outbox consistency and backend recovery. | Maintain bounded durable delivery and supported backends. Additional brokers need a demonstrated application requirement and their own recovery evidence. |
rullst-capital | High: provider-specific money/state semantics, reconciliation, subscriptions and fiscal expansion. | Preserve reusable SaaS billing: the provider-neutral base plus Stripe and an experimental InfinitePay adapter. The other adapters, payouts and NFS-e preparation were removed from 13.0 as described below. |
rullst-mail, rullst-ai, rullst-media | Medium to high: external services, feedback/events, transport changes and provider-specific behavior. | Maintain common contracts and adapters justified by actual use. Prefer interoperable protocols where appropriate, while retaining provider-specific assertions and explicit unsupported operations. Mail keeps Resend, AWS SES, SendPulse and SMTP in 13.0, as described below. |
rullst-privacy | Bounded current foundations; broader legal or biometric promises would create high continuing cost. | Keep reusable consent, minimal-data and proportional age-policy mechanisms. Provider attestations remain separate from native declarations; global legal certification and a first-party facial model are not part of this direction. |
rullst-supervision | Specialized, sensitive application state; models, capture and operational expansion raise the cost. | Keep transparent observation contracts as an optional education/parental extension. Product workflows, verified relationships, media models and human review belong to the application or separately governed integration. |
rullst-labs | Bounded trusted orchestration with sensitive authorization, leases and grading contracts. | Keep separate from execution: bring your own runner through the documented controller contract. Retain one useful Academy profile without making broader language or exercise support a framework release prerequisite. |
Labs execution (former rullst-labs-runner) | Very high security and operational burden despite its small source size. | Removed from the 13.0 workspace (30 September 2026); its source remains in git history. Applications deploy and review their own runner against the controller contract. Never move execution into the web process or weaken containment to simplify maintenance. |
rullst-studio, rullst-nexus | Medium, increasing with operational dashboards and autonomous administration. | Preserve useful local diagnostics and authorized administration. Avoid growth into a complete security-operations or autonomous infrastructure product. |
rullst-iot | The current helper surface is small; broad hardware and transport support would be expensive. | Preserve existing contracts and defer hardware expansion until there are named users, target devices and interoperability evidence. Treat future expansion as a specialized extension. |
These decisions refine the existing A/B priorities and overlapping C cost marker. They do not add milestones, change the 41-theme count or label an expensive capability dispensable merely because it is expensive.
Capital: retain billing, contain the responsibility
Owner-selected v13 scope, 8 October 2026 (superseding the 24 September
selection of Stripe and Paddle): rullst-capital keeps a maintainable
provider-neutral base and two adapters. The base is the billing traits,
checkout/subscription/entitlement/quota/usage contracts, billing analytics,
invoices, canonical webhook verification with replay protection, the Axum and
Actix adapters and the offline mocks. Crypto integrations are excluded from
this release.
| v13 provider | Reason and current boundary |
|---|---|
| Stripe | Supported. Existing SaaS use and the generated durable billing integration. Preserve the reviewed operation boundaries rather than promising every Stripe product. |
| InfinitePay | Experimental until validated against a live account. Offline fixtures only; live checkout and callbacks fail closed until a reviewed authoritative pricing and callback-authentication contract exists. |
Removed from the 13.0 workspace (8 October 2026; the source remains in git
history and in the 12.x line): the Paddle, Lemon Squeezy, Polar, Razorpay,
Mercado Pago, Alipay, Coinbase Commerce and PicPay adapters, the Wise payout
adapter with the payout contracts, the generated Paddle billing candidate and
the NFS-e preparation module (nfse feature). NFS-e was never validated with a
real municipality; it may return as a separate product outside Rullst.
Applications integrate other gateways by implementing the provider traits, as
shown in Writing your own payment provider; the
migration guide
lists the removed names. Do not silently redirect existing customers or
subscriptions to another provider.
Keeping these two adapters still incurs upstream API and security maintenance. The framework owns bounded protocol validation and reference lifecycle tests; applications own credentials, catalog setup, billing-owner permissions, business rules and deployment-specific operation. An adapter is not a promise to implement every provider product. Generated reference flows must reuse storage/security foundations and retain explicit limits, without growing into a full billing platform. Capital remains an optional feature. An application-specific need does not automatically justify another official adapter.
Crypto research is deferred beyond v13. Preserve applicable billing extension contracts, but do not add a crypto adapter, universal blockchain API, custody service or new crypto-specific release gate. The former Coinbase Commerce adapter was removed with the other excluded providers. Two findings remain relevant if a future application requests this:
- Coinbase’s Commerce transition notice sets 31 March 2026 as the Commerce shutdown deadline. Its Business availability page currently lists eligible receiving businesses in the United States and Singapore. Existing Commerce code is not a Business API implementation.
- BTCPay Server is an optional evaluation candidate, not a selected Rullst provider. Its altcoin FAQ separates the core Bitcoin scope from community integrations. Its August 2026 advisory records an exploited LND credential vulnerability fixed in 2.4.2. This dated reference does not identify the latest release or certify another deployment. Future evaluation must include current advisories, exact plugin/engine versions and operator maintenance, not merely API access.
Plan three explicit responsibilities, using the existing implementation as the starting point rather than rewriting it:
- Reusable billing contracts: amounts/currencies, supported checkout and subscription state, current entitlements, idempotency and safe event processing. Keep authorization and money/state integrity reusable instead of asking every generated application to reconstruct them.
- Provider adapters: document capabilities per operation and provider, version assumptions, signature verification, duplicate/out-of-order events, ambiguous outcomes and reconciliation. Select the actively supported expansion scope from real product demand and available validation. A common trait must not imply that every provider supports the same operations.
- Fiscal and specialized finance: outside the framework. The bounded NFS-e preparation was removed from 13.0 without live validation; fiscal work may return as a separately maintained product with its own lifecycle.
These responsibilities need not produce one crate per provider. Decide whether modules, features, packages or repositories provide a measurable benefit after checking dependencies, downstream consumers and release costs. Independent versioning is a future migration decision, not a property already implemented.
The removed adapters remain in the 12.x releases; that line reached end of life
on 16 October 2026, with 12.3.0 as its last release. In v13 each removed name has a migration row and an assisted-upgrade finding,
and the replacement is an application-owned adapter on the retained contracts.
The deprecation policy also
expects #[deprecated] markers in a released 12.x minor before a stable API is
removed. By owner decision on 8 October 2026, the 12.3.0 minor release marked
the Capital and Mail APIs removed here #[deprecated], pointing to their
migration rows, before 13.0.0 ships. The assisted-upgrade findings and the
fail-closed configuration errors apply in addition. Tests with deterministic mocks establish local
contracts, not actual provider interoperability. The prohibition on real-account
tests remains in force; outstanding external evidence stays explicitly pending.
The recurring obligations are concrete: Stripe documents duplicate events, delivery ordering and retries in its webhook guide, and version transitions in its API upgrade guide. Other providers require their own evidence rather than inferred equivalence.
Mail: four delivery transports
Owner-selected v13 scope, 8 October 2026: rullst-mail keeps the Resend,
AWS SES, SendPulse and SMTP transports, the log, memory and offline mock_*
development drivers, and the shared infrastructure they use: failover, the
REST helpers, the pre-flight pipeline, attachment inspection, suppression,
observations, verified Resend feedback and queued delivery. Removed from the
13.0 workspace (the source remains in Git history and in the 12.x line): the
SendGrid, Postmark, Mailjet, Mailtrap and Azure Communication Services
transports with their MAIL_DRIVER values and settings.
Upgrades must not lose mail or data silently. A configuration that still names
a removed driver fails facade resolution with a typed MailError::ConfigError
that names the driver and the
migration guide row
“Mail providers removed”; it never falls back to a development driver. Jobs
queued by 12.x carry no provider name and are delivered by the upgraded
worker’s configured driver, or fail visibly and stay retryable. Suppression
rows and delivery observations keep their provider labels and stay readable.
Applications integrate other providers by implementing MailDriver, as shown
in Writing your own mail transport. Like the
Capital removal, these transports are marked #[deprecated] in 12.3.0 before
13.0.0 ships. Tests with deterministic mocks establish local contracts, not provider
interoperability.
Turso: assess a complete application journey before universal parity
Assessment on 24 September 2026; implementation is not scheduled here. SQLite, PostgreSQL and MySQL/MariaDB are the principal SQLx ORM backends. MariaDB shares the MySQL driver but has its own executable container contract. This does not imply every optional crate or generated application supports all four databases: some domain stores deliberately have a SQLite-only or PostgreSQL-specific implementation.
The current Turso/libSQL path already supplies typed CRUD, equality filters, ordering, bounded pagination/counts, explicit SQL transactions and reversible checksummed migrations. Blank/API generation and selected generators have bounded acceptance; the remote matrix runs against an official libSQL server. SQLx-specific relations/hooks, schema auto-diff, seed generation, full blueprint parity and transparent replica synchronization remain outside that profile. See the current persistence contracts.
The remote adapter owns an HTTP/Hrana protocol implementation; local offline execution uses SQLite through SQLx. Reusing SQL syntax does not make those transports, row codecs, transactions or generated consumers interchangeable. The current official Rust guide also distinguishes libSQL, the newer Turso Database engine, remote clients and local-first sync. The existing libSQL evidence does not validate every newer engine or sync configuration. Select an engine/protocol/version profile before expanding support, and preserve Rust 1.96.0 compatibility.
| Potential scope | Qualitative maintenance assessment |
|---|---|
| Maintain the current bounded libSQL profile | Moderate: existing transport, macro, migration and local/remote acceptance obligations remain. |
| Complete one named SaaS journey on one selected profile | Medium to high initial effort; recurring work can be bounded by an explicit operation and platform matrix. Inventory actual Auth, billing, tenant, migration and recovery dependencies first. |
| Match every ORM feature, domain store and blueprint across remote/local/replicated modes and both engine families | High to very high: multiplies implementation and recovery/consistency validation, not just connection strings. |
These are engineering judgments from current boundaries, not measured hours or update-frequency forecasts. Retain useful Turso support. If an actual SaaS or Academy requirement selects expansion, implement the smallest complete journey and review its recurring cost before adding another profile. Validate atomic updates/rollback, tenant isolation, concurrency, uncertain network outcomes and schema changes. Keep replica freshness and authoritative authorization/payment decisions explicit. Local/container evidence remains distinct from managed Turso Cloud acceptance; real-account tests are still prohibited in this session.
Education, supervision and Labs remain supported use cases
Rullst remains a foundation for complete education, parental-supervision and exercise platforms. Preserve the useful domain capabilities already implemented in the optional packages, alongside authentication, tenant boundaries, storage and durable work. Their current candidate status and evidence limits still apply; this decision does not promote prerelease code to a stable guarantee.
| Layer | Responsibility |
|---|---|
| Framework foundations | Reusable identity, authorization helpers, persistence, queues, request protections and explicit extension contracts. |
| Optional domain packages | Existing Supervision observation/session/collection contracts and Labs exercise/job/lease/grading contracts, maintained within their declared boundaries. |
| Application | School or family workflows, authenticated membership and verified relationships, user interfaces, review decisions, exercise content, grading policies and model/provider selection. A developer or assistant may implement these through the documented contracts. |
| Separately deployed execution | An application-owned, reviewed runner implementing the Labs controller contract, with operator-owned isolation/resources for untrusted submissions. The application must not improvise execution in the web process. |
Keep small tested examples and supported integration paths where they serve actual users. Developers should be able to compose existing capabilities rather than recreate sessions, leases or observation protocols. They still own domain authorization and deployment obligations. Observation signals do not establish cheating, and an AI-written integration does not prove model accuracy or safe code execution. Each deployed Labs runner still needs its own isolation review.
Labs and the runner have different maintenance responsibilities
rullst-labs keeps the trusted exercise, authorization, submission/job,
cancellation, receipt-verification and grading contracts. It does not execute
the learner’s program. A runner is the separately deployed program that takes
authorized work, compiles/executes the submitted code under enforced limits,
returns a bounded authenticated result and tears down the execution environment.
Trusted grading and expected answers remain outside the untrusted worker.
Two kinds of separation must not be confused:
- Execution isolation is mandatory today. Student-controlled code can loop, exhaust resources or attempt to read data. Run it in the separately restricted worker without application secrets and enforce CPU, memory, time and output limits. Merely putting code in another crate or process is not adequate containment; the runner’s full observed isolation controls remain required.
- The runner’s maintenance is no longer a framework obligation. The Linux/compiler/sandbox matrix has different deployment and review obligations from an ordinary web library. This plan originally considered a separately maintained official runner in the same repository.
Decision of 30 September 2026: the experimental rullst-labs-runner
candidate is removed from the 13.0 workspace and is not published; its source
remains in git history. rullst-labs becomes a bring-your-own-runner library:
it keeps the trusted exercise, authorization, encrypted job, fenced lease,
cancellation, retention and pinned-receipt contracts, and documents the
controller contract an application-owned, separately
deployed runner must implement, with a minimal non-executing example. The
application operator now owns the runner’s isolation, review, deployment and
updates. Isolation evidence must match the deployed toolchain and image.
At this decision rullst-labs had publication disabled. It later joined the
release inventory and was first published in 13.0.0-alpha.1 on 11 October 2026;
it is not part of any 12.x release. There is one wire protocol and no
adapter layer; interchangeable external executors are not implemented. The
removed candidate’s first-profile record remains a
reference design, not a supported runner.
The current profile is a restricted Rust pure-function exercise named for Wasm on Linux. It does not run arbitrary Rullst projects or support every language. Do not imply production support for any runner, and do not ask applications to recreate isolation with an unrestricted process launcher.
What belongs in the framework in an AI-assisted workflow
Prioritize explicit APIs, typed errors, safe defaults, version-matched executable documentation, inspectable generators and predictable upgrades. Keep reusable authentication, tenant authorization, payment verification and data-integrity contracts centrally tested. AI-generated application code must still satisfy those contracts; an assistant is not a replacement for their review.
Application-specific forms, reports, pages and business workflows are good candidates for generated or manually written application code. Provide extension points and small tested examples rather than maintaining every possible product inside the framework. Application owners still maintain the resulting code. See the AI maintainability roadmap for proposed task fixtures and evaluation boundaries; this decision claims no model benchmark.
Transition sequence and acceptance
- Preserve published releases. Keep published releases immutable. The
12.x line reached end of life on 16 October 2026 (12.3.0 is its last
release) and the
v12branch is frozen;maindevelops v13. - Make support scope observable. For each proposed expansion or extraction, record the product user, maintained operations/platforms, dependencies, evidence gaps, responsible reviewer and estimated recurring work. Start with current SaaS and Academy journeys. The v13 billing-provider set is Stripe (supported) and InfinitePay (experimental), with crypto excluded; the other adapters and NFS-e were removed with migration guidance. The v13 mail transports are Resend, AWS SES, SendPulse and SMTP; the other transports were removed the same way.
- Prioritize separation and expansion freezes. The Labs runner’s assessment concluded with its removal from 13.0 in favor of a bring-your-own-runner contract, and Capital’s fiscal domain was removed from 13.0. Next assess IoT hardware expansion and specialized educational monitoring. Keep their existing bounded implementations and security duties visible. Reuse existing package boundaries where they already solve the problem.
- Review one bounded migration at a time. Demonstrate the benefit before extracting modules or changing defaults. Trace facade features, generated applications, direct package consumers, serialization and data migration. Do not create a collection of nominally separate but unsupported packages.
- Validate and integrate normally. Preserve workspace tests, strict Clippy, formatting, MSRV, feature-boundary, package-consumer and generated-application checks. A smaller future support matrix requires an explicit compatibility decision; it is not permission to skip today’s required evidence. Update the SST, manifests, release inventory, support documents and migration guide together when an actual boundary changes.
The current synchronized release train remains authoritative. Labs is in the release inventory as a contracts-only Experimental package and its runner candidate was removed; other crates in the inventory are not removed by calling them extensions. Their possible future independent release lifecycles must be explicitly implemented and validated. Unfinished optional roadmap work need not block a bounded v13 release, but unresolved safety or admission requirements for what it ships still do.
Retirement is appropriate only after a concrete review finds insufficient benefit, no sustainable maintainer or a better supported replacement. Follow the existing deprecation policy: retain a deprecated stable API for at least one released minor version before removal in the next major, subject to the documented security exception. Preserve migration guidance and history.
At each review, record actual maintenance effort where available: incident and dependency-update work, CI execution versus queue time, repeated failures, supported combinations and downstream acceptance. Keep unknown measurements unknown. Prefer freezing low-demand expansion over weakening required tests or transferring critical security logic into unreviewed application code.
v13 framework references and comparative evaluation
Planning direction adopted on 23 September 2026. Study useful designs from other frameworks and measure comparable application journeys to guide Rullst investment. This document specifies future evaluation, not measured results or a claim that Rullst is superior. The work was scheduled to start after the v12.1.1 maintenance release. The SST still governs architecture.
Relationship to the priorities
The 25 A priorities are broad themes to invest in or maintain, not 25 unfinished features that must all ship before evaluation. Some already have bounded implementations; others contain substantial optional follow-up. Start a small baseline once the selected application journeys are usable, then repeat relevant measurements after meaningful changes. Findings should help select the next investment instead of arriving only after the entire roadmap.
This work refines existing themes such as M1/M2/M3/M5/M9/M11/M12/M15 and the downstream fixtures, recovery and authorization proposals S1-S3. It adds no top-level milestone and does not change the 41-theme or 25/16 counts. It is not a new release gate, a promise to complete all themes for v13, or authorization to publish. Preserve the maintenance scope: common security contracts remain maintained, while specialized extensions need owners.
Reference frameworks
The following official documentation was consulted on 23 September 2026. These are design-study recommendations, not a ranked list or independent verification of the projects’ claims. Pin actual versions when executing a comparison; mutable documentation links alone do not identify tested source.
| Reference | Study focus | Rullst application |
|---|---|---|
| Loco | Integrated Rust models, controllers, authentication, mail, workers and CLI | Closest initial application-productivity comparison; evaluate complete generated journeys. |
| Laravel | Authenticated starter applications and a coherent initial experience | Reduce manual setup and improve the supported SaaS path. |
| Django security and admin | Integrated protections, documented limitations and administration | Review safe defaults, authorization recipes, documentation and Nexus boundaries. |
| Axum | Explicit HTTP APIs and Tower composition | Technical baseline for overhead and interoperability; Rullst already depends on Axum. |
| Actix Web | HTTP workers, connections and graceful shutdown | Targeted server lifecycle and load-behavior comparison. |
| Ruby on Rails | Coherent generators, models, migrations and deployment journey | Learn from conventions while preserving explicit, inspectable Rust contracts. |
| ASP.NET Core | Application-host and infrastructure integration testing | Make realistic HTTP, identity and persistence acceptance easier to run. |
| Phoenix LiveView | Interactive server-rendered applications | Study the supported realtime/UI journey when product demand selects it. |
| Leptos | Reactive and full-stack Rust interfaces | Evaluate a specific frontend requirement before expanding beyond SSR/HTMX. |
Begin with Loco for comparable Rust application work, Laravel for onboarding, Django for security/documentation study, and Axum for a small HTTP baseline. The other references are targeted follow-ups. Do not build and permanently maintain nine parallel application suites merely because nine references are listed. Adoption of an idea requires a demonstrated gap and a bounded change; the list does not authorize copying each framework’s architecture or features.
First application journeys
Use synthetic data, disposable databases, local provider fixtures and supported public APIs. Reuse existing SaaS/Academy regression fixtures where appropriate. Write equivalent acceptance contracts before timing implementations.
| Journey | Required observations |
|---|---|
| Create a small SaaS application | Install, generate, migrate, start and implement an authenticated resource; reject anonymous and cross-tenant access and prove allowed access. |
| Recover an account | Capture mail locally; exercise expiry, purpose binding, single use, concurrent reuse and the documented session policy. |
| Process a billing event | Use the same signed offline event contract; reject tampering, replay and cross-tenant mutation, and handle duplicate/out-of-order delivery without duplicate domain effects. This is not live gateway certification. |
| Recover background work | Interrupt a disposable worker, restart it and check leases, retries, idempotency and documented recovery behavior. Record delivery guarantees explicitly. |
| Update an existing application | Upgrade a pinned previous supported version, resolve required changes and verify preserved data, access and jobs. Exercise a declared rollback or restore procedure; do not assume migrations are reversible. |
The first executable pilot should cover the small SaaS journey and one authorization-negative case. Expand to account recovery, events, durable work and upgrades only as the acceptance fixtures and available resources permit. An unsupported journey is a recorded scope gap, not an invented successful run.
Bounded SaaS pilot
The opt-in cli-saas-journey diagnostic in the existing Rust CI workflow
implements the first Rullst acceptance contract. Dispatch it with
platform=ubuntu-latest; it is outside the default platform/shard matrix and
does not replace any required CI or release gate. Run records, not this source
description, establish whether a particular SHA passed.
Its canonical resource, migration and runner live in
examples/saas.
python3 examples/saas/run.py runs the diagnostic locally; --serve keeps the
same disposable application open for exploration without claiming a test pass.
The example README documents registration, local membership provisioning,
notes requests, disk requirements and cleanup.
gh workflow run ci.yml --ref <candidate-branch> \
-f platform=ubuntu-latest -f shard=cli-saas-journey
The script installs the reviewed source CLI in an isolated prefix, verifies its
version, generates the SQLite SaaS, checks its source dependency paths and runs
the installed db:migrate command twice. It adds one explicit application-owned
migration/resource, then checks the generated application with strict Clippy.
Real loopback HTTP exercises three disposable users: permitted registration,
login, create/read/update, wrong-password and anonymous denial, same-tenant
ownership denial, cross-tenant denial, forged tenant hints, CSRF, bounded input,
rejected owner/tenant mass assignment, persistence across an owned process
restart, fresh membership revocation and client-cookie logout.
The resource uses the starter’s real Auth middleware, database-owned membership,
TenantLayer, parameterized SQL and RbacGuard. The fixture operator provisions
membership directly in its disposable database; this is application code,
not automatic organization management in the SaaS blueprint. Neither a tenant
header nor a request body grants membership. A missing tenant hint can select
the authenticated user’s sole trusted membership, as documented by Core.
The additional resource mounts CSRF, secure headers and WAF explicitly, including
in this loopback development profile. The starter’s existing middleware and
Server’s production baseline remain unchanged.
saas-journey-ubuntu-latest retains sanitized JSON evidence even on failure:
source and lock identities, installed CLI version/hash, toolchain, phases,
acceptance outcomes, application fixture size and observed wall times. No
database, generated secrets or raw application output is uploaded. Failed
commands expose only a bounded, redacted diagnostic tail in the job log.
This first profile uses a source-installed debug CLI, a local SQLite file and HTTP clients. It does not establish archive/registry installation, browser cookie enforcement, production TLS/provider behavior, stateless-cookie server revocation, load capacity, application upgrades, rollback safety or superiority over another framework. Independent archive/platform suites retain their own source identities. Setup edits and compile/startup times are observable; active implementation effort and failed CI attempts must still be retained in the trial report. A small functional run cannot predict support for 1,000–10,000 concurrent users.
Local execution requires Linux, a fetched workspace lock and at least 32 GiB available on the source and temporary filesystems for a conservative cold-build allowance. The runner checks space between phases and during owned builds, stopping those builds at the 12 GiB reserve. Prefer hosted CI on workstations near the 15 GiB preventive threshold. This is an operating check, not a disk quota or protection against writes from unrelated processes.
Measurements
| Dimension | Record |
|---|---|
| Development effort | Setup and active implementation time, manual steps, relevant application code/configuration, documentation lookups, failed attempts and fixes. Report build/queue/wait time separately. Code size alone is not productivity. |
| Correctness and failure behavior | Acceptance and negative-case outcomes, reproducible defects, timeout/retry/recovery behavior, error clarity and diagnostic effort. More test files do not establish stronger protection. |
| Performance | Cold and warm builds, startup, representative authenticated/database requests, latency distributions including p50/p95/p99, throughput, errors, CPU and memory at declared loads. |
| Maintenance | A repeatable change such as adding an authorized field, updating a dependency or migrating a release; record time, touched components, regressions and downstream fixes. Longer-term incident/update effort remains unknown until observed. |
A fast implementation that fails the agreed security, durability or correctness contract does not qualify as a performance/productivity winner. Report the failure; do not disable protections to improve numbers. Internal Rullst scorecard grades are not comparative measurements.
Fairness and reproducibility
- Freeze task requirements, acceptance tests, effort budget and measurement method before the trial. Use idiomatic supported approaches on every stack. Give competing implementations a comparable debugging/tuning budget.
- Record framework/application commits, exact versions, dependency locks, toolchains, OS, machine/container limits, database version/schema/data, cache state and commands. Distinguish released software from unpublished candidates.
- Compare matching authentication, validation, persistence, durability and response semantics. A minimal Axum HTTP baseline is not a full SaaS competitor; disclose application code and libraries needed to assemble missing layers.
- Isolate the load generator, check for bottlenecks, warm up appropriately and repeat trials. Declare trial counts, load schedules, errors and variability; report inconclusive differences. Hosted CI timing is useful for regression detection but is noisy evidence for cross-framework performance rankings.
- Separate local fixture acceptance from provider/device/production evidence. Use only free resources and standard public CI runners where appropriate; respect the local disk reserve. No real provider accounts, production data, paid services or automatic deployments are authorized by this plan.
- For AI-assisted work, retain the actual model/version, tools, instructions, budgets and interventions. Keep the procedure comparable and disclose familiarity and tuning differences. One assistant’s result measures that assisted workflow, not universal human productivity or model capability. Reuse the AI evaluation protocol, without changing models automatically or claiming independent human validation.
- Keep scripts, sanitized fixtures, raw results and a source-bound report. Retain failed trials and tradeoffs; do not publish only favorable workloads or turn a weighted internal score into a universal league table.
Delivery sequence and acceptance
- After stable maintenance: select the first bounded SaaS task, references, supported versions and free execution budget. Confirm fixture reuse and document the threat/behavior contract before adding implementation work. The bounded SaaS pilot implements that task’s Rullst-side acceptance contract.
- Baseline: exercise usable Rullst source and the selected reference with the same acceptance contract. Record current gaps and uncertainty. Do not wait for every A priority to be completed and do not claim unrun results.
- One improvement: choose a measured problem with a clear user benefit, check the SST and maintenance cost, implement it, and rerun affected cases. Existing release and compatibility requirements still apply.
- Release review: when the selected v13 scope is ready, summarize the relevant measurements and remaining limits. Required release checks decide admission; this optional comparison cannot certify security or waive them.
- Maintenance checkpoints: repeat relevant cases after upgrades or material changes. Keep focused regressions in normal CI when useful; run expensive cross-framework campaigns explicitly at checkpoints, not on every PR.
An evaluation deliverable needs reproducible commands and source identities, actual acceptance results, comparable measurements, disclosed exclusions and a short prioritized response to the findings. A defensible conclusion names the specific workload and dimension in which Rullst improved, matched or lagged. The outcome may be to fix a defect, simplify a journey, retain an existing design or defer an expensive extension. There is no predetermined winner.
Nexus application integration plan
Status: proposed scope, reviewed on 25 September 2026, with progress noted up to 13.0.0-alpha.4. Except for the parts this page says exist, these are planned improvements, not available APIs. 13.0.0-alpha.4 is the 13.0 feature freeze, so the remaining stages are not part of 13.0. The current contract remains in the specification and Nexus guide.
The goal is to embed Nexus into an existing application without duplicating
its login, tenant policy or administration shell. Keep this work in
rullst-nexus; a new crate, a universal admin platform and automatic domain
authorization are outside this scope. Applications continue to own membership,
parental relationships, consent, business transactions and deployment policy.
This refines M11, with dependencies on M9/M12/M14 and a separate M32 macro ergonomics item. It does not add milestones to the 41-theme inventory in v13 priorities, or override the maintenance scope.
Existing foundation and remaining gaps
Preserve the existing authenticated policies, administrator role checks, parameterized CRUD, bounded inputs, server-side field restrictions, opt-in tenant scoping and transactional mutation audit. Their limits remain explicit: host membership and domain authorization are not inferred from registered models, and the mutation audit does not provide an immutable external log.
FieldMeta.hidden already excludes a field from list and form rendering, and
the input validator rejects writes to hidden fields. In v13 its field
documentation says so, and the list and edit views select only visible,
non-password columns (the list also reads the primary key); the end-of-life
v12 line documents the old form visibility. This rendering behavior is not yet
the never-read projection contract of stage 3. Field visibility, database projection and
authorization need distinct documented contracts.
Existing mobile/navigation improvements and pagination links are useful;
they do not establish an end-to-end administration journey without JavaScript.
Since this review, the existing shell serves its stylesheet, script, vendored
htmx 2.0.4 and logo same-origin under /nexus/assets without inline code or
external fonts, so it runs under the default production nonce CSP. A
configurable mount path and a translation catalog remain open. Since
13.0.0-alpha.2 the host-session identity part of stage 1 exists:
NexusAuthPolicy::application asks an application NexusAuthorizer to
validate the session and the administrator permission on every request before
data access, and Nexus creates the principal only after it allows the request
(see the Nexus guide). It
is a read-write mode with Nexus’s CSRF, role and audit boundaries, not the
read-only mode with domain-service writes planned below; the SaaS starter and
the LMS example use it with a generated users.is_admin check.
Delivery order
| Stage | Bounded result | Required behavior |
|---|---|---|
| 1 — identity and operations | An opt-in host-authenticated, read-only integration mode | Validate the current session and administrative permission separately on each request, before data access or rendering. Nexus creates the principal after successful validation. Reject direct mutation requests as well as hiding unavailable controls. |
| 1 — self-contained shell | Versioned local assets and a shell compatible with strict CSP | No required external scripts/fonts/CDNs, inline handlers or unsafe-inline. Test actual browser behavior and outgoing asset requests. Keep application security middleware active. |
| 2 — paths and language | A validated mount path and explicit translation catalog | Use the configured path in every link, form, redirect and endpoint; encode path segments and query values correctly. Support explicit locale selection and deterministic fallback, beginning with en/pt-BR/es. |
| 2 — usable SSR | Keyboard and no-JavaScript read journeys | Search uses GET forms, pagination uses links, and labels, focus, skip navigation and table captions remain usable at narrow widths. HTMX enhances an already working read journey. |
| 3 — data ownership | An explicit per-instance pool or bounded typed collection provider | Keep independent instances isolated. Support application-owned projections without requiring registration of sensitive base tables, arbitrary SQL input or a second ORM. |
| 3 — field and domain policy | Positive read/write projections and operation capabilities | Define never-read secrets, masked values and write-only updates separately. Recheck operation and tenant authorization on the server. Sensitive mutations invoke application domain services and preserve transactional audit requirements. |
| Throughout — optional modules | Explicit configuration of chat, security and telemetry surfaces | In the new integration mode, unconfigured modules have neither mounted routes nor menu entries. Preserve the existing supported mode until an explicit migration is designed. |
Stages describe ordering, not permission to ship an incomplete security boundary. Stage 1 may use an application-owned read renderer/provider, with a documented trusted-code boundary and mandatory escaping of interpolated data. It must not silently activate generic writes or trust client-supplied identity, roles or tenant IDs. Exact public type and method names require design review; an application’s private extension is evidence of demand, not a stable API.
Prefer generic/static-dispatch integration contracts and typed failures. Keep the existing Basic credential validation, transport requirements and verified development-loopback restrictions. A public unrestricted principal constructor is not the session integration contract. Revocation must take effect on the next request, including HTMX fragments and direct resource URLs.
Before adding sensitive write operations, define domain-service delegation, CSRF handling, authorization, transaction boundaries and any required reauthentication. Do not let generic CRUD bypass consent, family relationships or account-deletion workflows. Additional read-access auditing and assurance levels remain separately scoped work; the first read-only mode does not claim to provide them.
v12 boundary (historical)
This section recorded the boundary while v12 was the maintained stable line; v12 reached end of life on 16 October 2026 and receives no further changes (see the security policy). The plan was to correct inaccurate field documentation and add a tested recipe for conditional HTML boolean attributes there, preserving its APIs, MSRV and serialized contracts; new public integration features were never patch backports.
For HTML boolean attributes, presence means true: selected="false" does not
disable selection (see MDN’s boolean attribute semantics).
Since 13.0.0-alpha.2, html! writes a conditional attribute as
name?={value}: a bool toggles presence and an Option omits the attribute
for None, while name={value} keeps writing the escaped value as text, which
is right for aria-* and data-* attributes. On v12 the safe recipe emits the
complete selected or unselected element, with a quoted selected="selected"
only in the selected branch.
Acceptance and evidence
Use synthetic downstream fixtures and offline browser tests; no application production data or provider accounts are needed. Test the following observable properties alongside the unchanged workspace and release requirements:
- Missing, expired and revoked sessions fail before the provider is called; ordinary authenticated users still cannot administer records. Cover direct URLs, fragments, unsupported methods and attempts to replace tenant context.
- A read-only configuration cannot mutate through individual, batch or custom routes. Existing tenant predicates and required-audit rollback remain intact in any mode that enables writes.
- Two instances with different pools, policies and prefixes cannot share data or identity. A never-read field is absent from the query projection, response, logs and audit payload, not merely hidden with CSS.
- The mounted shell passes strict CSP and resource-request assertions in a real browser, on success and denial paths. All locales and mount paths keep working with JavaScript disabled; verify actual form submission and results.
- Keyboard navigation, focus and table semantics work at 320/390/1440 px. Conditional boolean-attribute fixtures assert the browser’s selected value and submitted query, rather than only searching generated HTML strings.
- New APIs compile on Rust 1.96.0 and the declared supported platforms. Retain the full tests, Clippy, formatting, packaging and applicable admission gates; passing a downstream application’s tests is not framework release admission.
Apart from the parts this page says exist, no part of this document claims that these APIs, browser journeys or release checks have already been implemented or passed. Scope implementation after 13.0, starting with one complete read-only journey.
AI Maintainability and Project-Building Roadmap
Rullst aims to be understandable to humans and coding assistants without making model-independent quality claims. Explicit Rust types, conventional project structure and generated context can reduce ambiguity, but no framework can guarantee that an arbitrary model will produce correct, secure or maintainable software.
This document records the measurable work required to make Rullst excellent for AI-assisted framework maintenance and application development. It is a v13 roadmap, not a v12 release gate and not a description of capabilities that have already shipped.
Current foundation
The repository already provides useful controls:
AGENTS.mddefines the framework architecture, coding invariants, security boundaries, validation commands and release order.spec.mdis the architectural Single Source of Truth.capability-status.mdand thequality-scorecard.mdseparate implementation claims from engineering evidence.cargo rullst make:*emits conventional, inspectable Rust source instead of hiding application behavior behind runtime reflection.- The v13
generate:ai-contextcandidate emits a bounded metadata inventory, a freshness check and preserved project instructions; see the implemented candidate scope. - Generated-project tests, compile-fail tests, deterministic provider mocks and the workspace validation gates catch classes of mistakes independently of the assistant that proposed a change.
These controls are a good foundation. They do not yet prove that a smaller or less capable model can maintain the framework or build a complete application at a defined quality level.
Known limitations
The v12 context generator (the 12.x line reached end of life on 16 October 2026) concatenated manifest/source content. The v13 candidate replaces that with a bounded metadata inventory and preserved application instructions. It still does not infer a complete semantic contract for routes, configuration behavior, model relationships, tests or authorization. Task-ranked semantic views remain follow-up work.
The framework documentation is extensive. Breadth helps difficult work, but a model with weaker retrieval or reasoning can select an obsolete example, miss a more specific invariant or consume too much context unless it receives a short task-oriented map first.
Maintaining framework internals is also substantially harder than building an application from stable public APIs. Cross-crate feature unification, procedural macros, database dialects, authentication, cryptography and release engineering require stronger review and broader gates than ordinary application CRUD.
v13 workstreams
1. Project-specific agent instructions
The first v13 candidate creates concise common application instructions and points to the fresh file/dependency inventory; existing instructions remain maintainer-owned. Future blueprint-specific semantic profiles should describe:
- the chosen database, rendering mode and enabled Rullst subsystems;
- the canonical locations for routes, models, controllers, policies, migrations, jobs and tests;
- the exact format, lint and test commands for that application;
- mandatory ownership, CSRF, headers, WAF and secret-handling boundaries;
- which generated files are application-owned and which commands may refresh them; and
- links to version-matched Rullst documentation.
Acceptance requires snapshot tests for every supported blueprint and a generated-project compile gate. The generated document must not claim that an optional integration is active merely because its crate is available.
2. Structured and bounded AI context
The v13 candidate now supplies deterministic bounded file/dependency/key metadata, freshness checks and project instructions. It deliberately excludes source excerpts and does not infer domain semantics. Broader future maps should add explicitly tested contracts for:
- dependency and feature selections;
- routes and their authentication/ownership policies;
- models, relationships and migrations;
- controllers, middleware, jobs and external-provider boundaries;
- configuration keys by name, never secret values;
- tests and the commands that exercise each subsystem; and
- freshness metadata sufficient to detect stale generated context.
The generator must enforce an explicit size budget, stable ordering, secret and binary exclusions, path confinement and deterministic output. Large projects should receive an index plus task-selectable context shards rather than one unbounded prompt payload.
3. Golden application tasks
Create small, executable reference tasks that represent common real work:
- add a validated CRUD resource with tenant ownership;
- add session authentication and a role-protected route;
- add a migration and repository query without SQL injection;
- enqueue an idempotent background effect through an outbox;
- verify a signed webhook and reject replay or invalid signatures;
- add a provider integration with a deterministic offline mock;
- diagnose and repair a deliberately broken generated application; and
- perform an assisted framework upgrade and verify rollback.
Each task needs a fixed starting fixture, an executable acceptance suite, security-negative cases and a reference solution. Tutorials alone are not evidence; the fixture must compile and its assertions must prove the behavior.
4. Reproducible AI evaluation harness
Evaluate candidate assistants using the same repository revision, task fixture, tool permissions, time budget and acceptance tests. Record at least:
- functional correctness and hidden-test pass rate;
- formatting, strict Clippy and test results;
- security-invariant violations;
- unsupported or hallucinated APIs and dependencies;
- unnecessary diff size and changes outside the requested scope;
- ability to recover from compiler and test failures;
- documentation truthfulness; and
- elapsed time, model/tool configuration and cost when publishable.
Results must name the exact model/version, agent harness, reasoning setting, date and commit SHA. A vendor description or one successful demonstration is not compatibility evidence. Preview and mutable model aliases must be reported as such.
The first matrix should distinguish at least two profiles:
- application builder: works through documented, stable public APIs and generated projects; and
- framework maintainer: changes crate internals, feature combinations, macros, security boundaries or release infrastructure.
A model may qualify for one profile without qualifying for the other.
The v13 framework comparison plan reuses these metadata and acceptance principles for application journeys across frameworks. Keep model/workflow comparisons distinct from framework comparisons, disclose confounding factors and do not infer universal superiority from one trial.
5. Task-oriented documentation routing
Add a short machine-readable and human-readable entry map that answers “which document should I read for this task?” before exposing the full manual. It must route architecture changes to the SST, release work to the release programme, security changes to the relevant threat model and ordinary application work to the smallest applicable tutorial and API reference.
Where documents overlap, one must be declared authoritative and the others must link to it rather than restating mutable facts. Documentation examples should continue to be compiled or exercised wherever practical.
6. Governance and review boundaries
AI assistance never replaces contributor accountability or independent review.
The disclosure and verification rules in CONTRIBUTING.md
apply to every model and tool. Exact prompt disclosure remains optional context,
not proof of authorship, completeness or safety.
Routine documentation, test and mechanical dependency work may use a faster model when the gates prove the result. Authentication, cryptography, ORM/macro contracts, release signing and cross-crate security changes require heightened human review regardless of the model used. No evaluation score authorizes unattended merge or production mutation.
Planned additions (recorded 2026-10-01)
These items were requested by the maintainer during the v13 review and are tracked here so they are not lost; they are plans, not shipped capabilities, unless marked as delivered.
- Documentation for humans and assistants: a “start here” page, short
per-crate quickstarts, a task-oriented cookbook, and an
llms.txtindex of the book. Delivered on 8 October 2026: the Pages workflow publishesllms.txtandllms-full.txtfrom the book, and visible maturity tiers per crate and per CLI generator (see maturity tiers). - Code-level clarity: every public item documented with a compiling
example (enforced with
missing_docsand doctests), consistent constructor and error patterns across crates, and actionable error messages (the v13 CLI error reports are the first step). - Assistant grounding: keep the
generate:ai-contextoutput and the publishedllms.txtin sync with the book, with a test that every command and API they name exists. (Thecargo rullst aiprimer went with the terminal assistant, removed in 13.0.0-alpha.4.) - Assisted v12 → v13 upgrade (delivered on
main):cargo rullst upgradeperforms the backed-up dependency upgrade,cargo fixandcargo check, and reports source findings fromrullst-upgrade-rules-v4; every row of the v13 migration guide is classified as must-change, review or not detectable. See the assisted upgrade tutorial. - Legal and regulatory readiness: see the legal & regulatory compliance roadmap.
Delivery sequence
| Phase | Delivery | Promotion evidence |
|---|---|---|
| A | Generated application AGENTS.md and context format v2 | Blueprint snapshots, deterministic output, secret-exclusion and generated-project gates |
| B | Golden task fixtures and evaluation runner | Fixed inputs, hidden assertions, security-negative tests and reproducible metadata |
| C | Public model/harness results | Exact versions, commit SHA, limitations and repeatable commands |
| D | Continuous regression programme | Scheduled or manual reruns, versioned baselines and reviewed score changes |
The v12 line reached end of life on 16 October 2026, so every phase belongs to v13 or later. 13.0.0-alpha.4 is the 13.0 feature freeze and 13.0.0-rc.1 takes only fixes and documentation: new generator formats, fixtures and public support profiles that are not in alpha.4 come after 13.0 unless a separate release decision says otherwise.
Completion criteria
This roadmap may be called complete only when:
- every supported blueprint produces accurate, tested agent instructions;
- context generation is bounded, deterministic, secret-safe and task-routable;
- the golden application and maintenance suites run from clean fixtures;
- published results are tied to exact models, harnesses, commits and dates;
- at least one lower-cost model completes the application-builder profile at the project-defined quality threshold without security-critical failures; and
- failure cases and unsupported maintenance classes are documented as clearly as successful ones.
Until then, the honest claim is narrower: Rullst is intentionally structured to support AI-assisted work and provides useful safeguards, but model suitability must be evaluated for the concrete task and reviewed like any other contribution.
Non-goals
- guaranteeing correct output from every current or future model;
- detecting all AI-generated code or proving the complete prompt history;
- replacing human ownership, security review or coordinated disclosure;
- auto-merging changes based on a model name or benchmark score; or
- claiming superiority over other frameworks without dated, reproducible comparative evidence.
Legal & Regulatory Compliance Roadmap
Not legal advice. This page is an engineering roadmap written by the framework maintainers. It does not create a lawyer–client relationship and is not a statement that any application built with Rullst complies with any law. Every item below must be validated by qualified counsel in each jurisdiction before it is described as satisfying a legal requirement.
Status: planned (recorded 2026-10-01). Nothing on this page is a shipped capability unless it links to existing documentation. 13.0.0-alpha.4 is the 13.0 feature freeze, so the planned work below comes after 13.0.
Goal and honest boundary
A framework cannot make an application lawful. Compliance depends on what the operator collects, why, where it is stored, which contracts and notices exist, who the users are and which country’s law applies. What Rullst can do is make the default path compliance-ready:
- ship building blocks that implement common obligations correctly (consent, data-subject rights, retention, minimization, security, audit);
- generate starters whose defaults do not create avoidable legal risk (no unnecessary personal data, no non-essential cookies, no PII in logs);
- document, per regulation, which obligations a Rullst feature helps with and which ones remain the operator’s responsibility; and
- keep the framework’s own posture clear (MIT license and warranty disclaimer, SBOM, coordinated vulnerability disclosure).
Priority jurisdictions
The first guide covers the regulations most likely to affect Rullst users. Each row must later link to the official text and be reviewed by counsel.
| Region | Main instruments | Areas where Rullst can help |
|---|---|---|
| European Union | GDPR; ePrivacy Directive (cookies); AI Act (transparency obligations); Cyber Resilience Act; European Accessibility Act; Digital Services Act (platforms); NIS2 | Lawful-basis and consent records, data-subject rights, records of processing, breach-response evidence, cookie defaults, AI content disclosure, SBOM and vulnerability handling, accessible starters |
| United States | State privacy laws (e.g. CCPA/CPRA); COPPA; FERPA (education records); HIPAA and GLBA (sector-specific); ADA accessibility; CAN-SPAM; FTC Act; state breach-notification and biometric laws (e.g. Illinois BIPA) | Opt-out and deletion flows, children’s data gates, education-record handling in the LMS example, unsubscribe headers, accessibility, biometric-free supervision defaults |
| Brazil | LGPD and ANPD regulations (incl. incident reporting); Marco Civil da Internet (application access-log retention); Código de Defesa do Consumidor and e-commerce rules; ECA Digital (children and adolescents online); Lei Brasileira de Inclusão (accessibility) | Consent and rights, incident timelines, configurable access-log retention, consumer information pages, age assurance and parental supervision, accessible starters |
| Asia | China PIPL, Data Security Law and Cybersecurity Law (localization and cross-border transfer); Japan APPI; South Korea PIPA; India DPDP Act and Rules; Singapore PDPA; others (Indonesia, Vietnam, Thailand) | Data-residency configuration, cross-border transfer inventory, consent and rights flows, retention, breach-response evidence |
| Global / industry | PCI DSS (payment cards); OWASP ASVS; open-source license obligations; export controls on cryptography | Hosted-checkout-only payment flows (no card data stored), security baseline mapping, SBOM and license audit |
What already exists (v13)
These are building blocks, not compliance claims; see each page for limits:
rullst-privacy: proportional age assurance, consent/withdrawal state and a bounded own-account profile export (roadmap and scope).- ORM field encryption and masking,
SecretString,#[privacy]personal-data tagging andComplianceModel::compliance_schema()/personal_fields(). - Security baseline: CSRF, secure headers and CSP, WAF/RASP, DLP and PII masking on responses, audit logging, rate limiting (security architecture).
- Mail: List-Unsubscribe (RFC 8058), recipient suppression, minimized delivery observations (mail crate).
- Capital: provider-hosted checkout and webhook verification. Fiscal documents such as NFS-e are outside the framework since v13.
- Supervision: no media models or biometric identification are included; the host owns capture permission and reviewer workflow.
- Supply chain: SBOM generation, license policy (
deny.toml) and coordinated vulnerability disclosure.
Planned work
| # | Item | Notes |
|---|---|---|
| 1 | Compliance guide in the book, one section per regulation above | Obligation → Rullst feature → operator responsibility → official source; reviewed by counsel before publication as guidance |
| 2 | Records of processing / data inventory generated from #[privacy] tags and compliance_schema() | e.g. a cargo rullst privacy:inventory report (fields, purposes, retention, processors, transfer regions) |
| 3 | Data-subject rights toolkit | Access/portability export, rectification, deletion and anonymization across models, with audit trail and deadlines |
| 4 | Retention policies | Declarative per-model retention, scheduled purge/anonymization, configurable access-log retention (e.g. Marco Civil) |
| 5 | Cookie and tracking defaults | Starters keep strictly necessary cookies only; an optional consent component gates analytics and marketing |
| 6 | Incident-response support | Security-event export and a breach timeline template (e.g. 72 h under GDPR, ANPD deadlines) |
| 7 | Children and education | Age assurance and parental-consent flows (COPPA, ECA Digital, GDPR Art. 8); FERPA/LGPD notes for the LMS example |
| 8 | AI transparency | Disclosure helpers for chatbots and AI-generated content (AI Act), provider data-use notes in rullst-ai |
| 9 | Accessibility | WCAG 2.2 AA targets for starters, Studio and Nexus, with automated checks in CI |
| 10 | Data residency | Region configuration and documentation for storage, mail and AI providers (PIPL and transfer rules) |
| 11 | Legal page scaffolds (optional) | Privacy policy, terms and cookie notice templates with explicit placeholders and a prominent “review with counsel” warning |
| 12 | Framework posture | CRA position for an open-source project, release security attestations, license and trademark guidance |
Principles
- Privacy by default: starters collect the minimum, never log secrets or personal data, and keep optional processing off until enabled.
- Evidence over claims: every helper ships tests and documents what it does not cover, like the rest of Rullst.
- No hidden legal advice: templates and guides are starting points with clear placeholders, never presented as complete or jurisdiction-proof.
- Small surface: features land as optional building blocks so applications that do not need them pay nothing.
Green Software Roadmap
Status: planned (recorded 2026-10-01) for the v13 feature line. Nothing on
this page is a shipped capability unless it links to existing documentation.
Item 1 and parts of items 3, 4, 6 and 7 exist in the v13 development source
(13.0.0-alpha.4); see measured release defaults.
13.0.0-alpha.4 is the 13.0 feature freeze, so the remaining work comes after
13.0.
Goal
Rullst applications are compiled Rust services that render HTML on the server with little client-side JavaScript. Independent studies of programming-language energy efficiency rank Rust among the most efficient languages, and a small, long-running native process usually needs less CPU and memory than an interpreted stack for the same work. That is a real advantage, but it must be measured per application, not assumed.
The goal is to make the efficient path the default, and to let each team measure and report the footprint of its own Rullst application with a recognised, reproducible methodology.
Honest boundary: reports, not “greener than” certificates
Rullst will not issue certificates claiming that an application is “greener than” one built with another framework. Such comparative or generic environmental claims are regulated:
- In the European Union, Directive (EU) 2024/825 restricts generic environmental claims and requires claims to be substantiated.
- In the United States, the FTC Green Guides govern environmental marketing.
- In Brazil, consumer law and advertising self-regulation prohibit misleading environmental claims.
An unsubstantiated “green” badge could expose Rullst users to the very legal risk the legal & regulatory compliance roadmap aims to reduce. Instead, Rullst will produce dated, reproducible measurement reports that state the method, the environment and the limits, which teams can choose to publish.
Planned work
| # | Item | Notes |
|---|---|---|
| 1 | cargo rullst green report | 13.0.0-alpha.4 development source, Experimental command of the CLI: cargo rullst green (formerly footprint) reports only measured figures, each with its method: release binary size, a named local container image’s size, the started app’s idle resident memory, the Cargo.lock dependency count and, with --bench, the app’s CPU time per request under a bounded loopback load. It measures no energy; a Software Carbon Intensity figure (SCI, ISO/IEC 21031:2024) appears only as a labelled estimate from the measured CPU time and the power figure and grid intensity you supply. Still planned: a fixed multi-route reference scenario, repeated runs with a reported spread, and stated uncertainty for the estimate’s inputs. |
| 2 | Reproducible public benchmark | The same reference application implemented idiomatically in Rullst and in other frameworks, with published code, scenario, hardware and dates, so comparisons are verifiable instead of claimed. |
| 3 | Efficient release defaults | v13 source: generated projects get a tuned [profile.release] (thin LTO, one codegen unit, stripped symbols; panic stays unwind so one panicking handler cannot stop the server) and a distroless runtime image (CLI reference). Remaining: measured image sizes and fast start-up suitable for scale-to-zero hosting. |
| 4 | Efficient HTTP defaults | v13 source: the standard /static mount sends Cache-Control: public, max-age=31536000, immutable for content-hashed file names and no-cache with ETag/Last-Modified revalidation (304) for other files (static assets). Pre-compressed assets already exist: cargo rullst build writes Brotli/Zstandard siblings that the Core static handler serves. Remaining: fingerprinted names written by the asset pipeline itself and modern image formats. |
| 5 | Lean pages | Keep zero-bundle HTMX rendering the default, report page weight in cargo rullst dash, and warn on oversized assets. |
| 6 | Database efficiency | Development-time N+1 query warnings are in the v13 dash (N+1 query warning); slow-query hints are already visible there. Default pagination and index suggestions remain planned. |
| 7 | Carbon-aware jobs | Started in v13: see carbon-aware deferrable jobs. Region selection remains planned. |
| 8 | AI efficiency | Token usage is already reported by rullst-ai; add response caching, prompt-size budgets and guidance for choosing smaller or local models when they are sufficient. |
| 9 | Resource view in the dash | Show the app process’s CPU and memory next to the request metrics so developers see the cost of a change while they work. |
| 10 | Green hosting guidance | Document how to choose lower-carbon regions and providers, and show region information during cargo rullst deploy when it is available. |
Measured release defaults
Measured on 2026-10-08 with the 13.0.0-alpha.1 development source. The
numbers describe this one starter on this one machine; they are not a
prediction for other applications or hardware.
- Application:
cargo rullst new green_probe --default --skip-initial-migration(Blank starter, SQLite;rullstfeaturesorm,strict-sqlite,studio), dependency versions from the repositoryCargo.lock, built--offline. - Toolchain: Rust 1.98.1 (
RUSTUP_TOOLCHAIN=1.98.1),x86_64-unknown-linux-gnu, rustc’s default linker (the generated.cargo/config.tomlselected neither mold nor lld). - Machine: AMD Ryzen 5 7520U (8 logical CPUs), 3.4 GiB RAM, NVMe disk,
Ubuntu with GCC 15.2,
CARGO_BUILD_JOBS=2, a shared developer workstation. - Command:
/usr/bin/time -v cargo build --release --offline --target-dir <new empty directory>; size fromls -l <target-dir>/release/green_probe. - Samples: two cold builds per profile, in alternating order (default then generated, then generated then default).
[profile.release] | Binary size (bytes) | Wall-clock build (run 1 / run 2) | User CPU time (run 1 / run 2) | Largest process RSS (run 1 / run 2) |
|---|---|---|---|---|
| Cargo’s default (section absent) | 13,966,600 | 6:17 / 6:17 | 648 s / 648 s | 723 MiB / 726 MiB |
Generated: lto = "thin", codegen-units = 1, strip = "symbols" | 7,776,880 (−44.3 %) | 5:20 / 5:18 | 517 s / 516 s | 923 MiB / 970 MiB |
Each profile produced the same size in both runs. For comparison,
strip --strip-all applied to the default binary alone gives 10,031,552 bytes
(−28.2 %); thin LTO and one codegen unit account for the rest.
Limits of this measurement:
- With only two parallel jobs, a single codegen unit costs little parallelism; the lower CPU time is consistent with skipping the per-crate local ThinLTO across 16 units, but that cause was not isolated. With more parallel jobs the wall-clock result may reverse; that was not measured.
lto = true(fat LTO) was not measured: it needs more memory than this machine could spare alongside other work.- Runtime behaviour (requests per second, CPU time per request, memory) and energy were not measured.
- Container image size was not measured: Docker is not available on this
machine. Measure the generated distroless image with
docker image lsin CI or on a machine with Docker before publishing a number.
Carbon-aware deferrable jobs (v13)
Opt-in; first published in the 13.0.0-alpha.1 prerelease. A queue job or scheduled task can be marked deferrable with a deadline and daily time windows; see deferrable jobs and time windows. The mechanism:
- Time windows. The job becomes claimable at the start of the next allowed window (immediately inside an open one), never after its deadline; when no window opens in time, at the deadline.
- Optional intensity source. An application-provided
CarbonIntensitySourcereturns forecast slots with their unit. ACarbonAwarePlannerplaces the job at the lowest-value slot inside its windows before the deadline. Core ships no network source; if the source fails or times out, the window rule applies and one warning is logged per outage. - Persistence. Placement uses the queue’s existing scheduled-job timestamp, so no schema changes and 12.x rows keep working.
- Observability. The chosen time, the reason (
window,intensityordeadline) and the source name are returned inDeferredJoband recorded on arullst.queue.deferraltracing span.
The planner only shifts when a job runs, using the values the source reports. Whether that changes an application’s emissions depends on the grid, the workload and the data; Rullst does not measure or claim it.
Principles
- Measure, then claim: every number comes from a stated method and environment; estimates are labelled as estimates with their uncertainty.
- Defaults over options: efficiency should not require expert tuning.
- No hidden network calls: carbon-intensity data sources are opt-in, with an offline default.
- Small surface: measurement tooling lives in the CLI and development builds, never in production request paths.
Efficiency Figures for an App (cargo rullst green)
Status: experimental command of the main CLI, cargo-rullst
(13.0.0-alpha.4 and later; it replaces cargo rullst footprint). Part of the
green software roadmap.
cargo rullst green reports efficiency figures it measured for your
application, says how it measured each one, and says which ones it could not
measure. It measures no energy. A carbon figure appears only as a Software
Carbon Intensity (SCI) estimate, computed from a power figure and a grid
intensity that you supply, and it is labelled estimate in the text and the
JSON together with every input. The report describes one run on one machine;
it is not a certification and it does not compare your application with other
software.
Install
green is part of the CLI; nothing else to install:
cargo install cargo-rullst --version =13.0.0-alpha.4 --locked
Its flags are listed in the CLI reference.
# Build the release binary, start it on a free loopback port, measure, stop it
cargo rullst green
# Also measure a local container image and run a 10-second benchmark
cargo rullst green --image my-app:latest --bench --duration 10s
# Measure an app that is already running on loopback
cargo rullst green --url http://127.0.0.1:3000 --path /health --bench
# Add an SCI estimate from your own power figure and grid intensity
cargo rullst green --bench --cpu-watts 15 --grid-intensity 120 --json
What is measured
| Figure | Method | Notes |
|---|---|---|
| Binary size | file size of the release binary built by cargo build --release (or of /proc/<pid>/exe with --url) | Size on disk, not memory use. |
| Image size | docker image inspect --format {{.Size}} (then podman) of the image you name with --image | Uncompressed size reported by the local engine. Only on a local socket (DOCKER_HOST/CONTAINER_HOST unset or unix:///npipe://); otherwise, or without --image, NOT MEASURED with the reason. |
| Idle RSS | VmRSS from /proc/<pid>/status after start-up, before any benchmark request | Linux only. |
| Dependencies | packages with a registry or git source in the nearest Cargo.lock | Every third-party crate Cargo resolved, for all targets and features; path and workspace packages are not counted. |
CPU time (with --bench) | utime + stime delta of the app process over the benchmark, from /proc/<pid>/stat and the clock tick rate | Linux only; all threads of the process, not child processes. |
CPU per request (with --bench) | CPU time ÷ completed requests, in milliseconds | Responses of any status count. |
A value that could not be measured reads NOT MEASURED and keeps the reason.
Nothing else is reported: there is no throughput score, energy reading or
rating.
What happens during a run
- Target. Without
--url, green runscargo build --release(Cargo reuses an up-to-date build), starts the binary from the project root withRULLST_ENV=production,HOST=127.0.0.1andPORT=<free port>, and waits up to 60 seconds for the first HTTP response toGET --path. It stops the app with SIGTERM (then a kill after 5 seconds) when the run ends. Production refuses to start without production settings, such as the SaaS starter’s real billing credentials; set them, or pass--env staging, which accepts the starters’ mock credentials and is recorded in the report. With--url, it measures an app that is already running. The URL must behttp://on a loopback address (127.0.0.0/8,::1orlocalhost, which is pinned to127.0.0.1); anything else is refused before a request is sent. green finds the process listening on the port by matching the socket in/proc/net/tcp{,6}with/proc/<pid>/fd; it only sees your own processes. - Benchmark (only with
--bench).--concurrencyconnections (default 4) each sendGET --path(default/), wait for the whole response body, then send the next request, until--duration(default 10 s, 1 s to 10 min) ends. Proxies are ignored and redirects are not followed, so no request leaves loopback. The load generator runs on the same machine; its own CPU use is not part of the app’s CPU time. - Report. Terminal tables (plain text under
NO_COLOR,CIor when stdout is not a terminal) or, with--json, the versionedrullst.green.v1document.
The command makes no network calls beyond loopback and the local container
engine, and never fetches carbon intensity data. The release build itself may
download crates as any cargo build does.
What is estimated: SCI
green follows the Software Carbon Intensity specification (ISO/IEC 21031:2024):
SCI = ((E × I) + M) / R
| Term | Source | Kind |
|---|---|---|
| E | measured CPU time × --cpu-watts, in kWh | estimate: green measures no energy |
| I | --grid-intensity in gCO2e/kWh | yours, never fetched |
| M | --embodied in gCO2e, the share of hardware emissions you allocate to this run | yours; not included when absent |
| R | requests completed in the benchmark; the functional unit is one HTTP request | measured |
--cpu-watts and --grid-intensity require --bench (and each other in that
order), so the estimate is either computed from all of its inputs or not at
all. Without them the SCI row reads NOT ESTIMATED and lists the missing
inputs. The --cpu-watts figure is your assumption of power per busy CPU
second (from the processor’s documentation or your own measurement); the
estimate ignores memory, disk, network and idle power.
Choosing a grid intensity
Use a value from a source you can cite for the place and time the software runs, such as your electricity supplier’s disclosure, your grid operator or energy agency’s published average, or a dataset your organisation already uses. Record the value, the source, the year or hour it describes and whether it is an average or a marginal figure next to the report. green never looks it up, so the same inputs always give the same result.
Why results vary
Numbers depend on the CPU model and its frequency scaling, power profile,
thermal state, operating system, build profile, database contents, the request
path, concurrency, and other processes on the machine (including the load
generator). Compare runs only on the same machine with the same setup, repeat
runs and look at the spread, and publish the report together with its
machine and benchmark sections. The report header records the time, OS,
architecture, logical CPU count and CPU model.
JSON report
--json prints one document whose schema (and schema_version) is
"rullst.green.v1", with cli_version, measured_at and the machine
section. Every key is always present (null when unknown) so scripts can rely
on the shape:
target:mode(release_buildorexisting_url), the URL, PID and how it was found.measured:binary_size_bytes,image_size_bytes,idle_rss_bytesanddependency_count, each{status, value, unit, method, reason}withstatusmeasuredornot_measured.benchmark:status(runornot_run), its inputs, requests, errors, andcpu_time_secondsandcpu_ms_per_requestin the same metric shape.sci_estimate:status(estimateornot_computed),label(alwaysestimate), the standard, formula and energy method, every input ininputs,energy_kwh,sci_g_per_requestandmissing_inputs.notes: the fixed caveats printed under the tables.
Exit status
0 when the measurement ran, even with NOT MEASURED fields; 1 when the
release build failed or the app could not be started or reached; 2 for
invalid arguments, including a non-loopback --url or an estimate input
without --bench.
Changes from footprint
cargo rullst footprint (13.0.0-alpha.1 to alpha.3) is now cargo rullst green, in the same cargo-rullst package. It no longer reports requests per second,
latency percentiles or peak RSS, no longer reads RAPL energy counters (whole
package, including other processes) and no longer guesses an image name from
the package name. The benchmark runs only with --bench, and the JSON schema
is rullst.green.v1 instead of rullst.cli-footprint.v1.
Rullst Labs roadmap: bring your own runner
Status:
rullst-labsis an Experimental v13 package, published since 13.0.0-alpha.1 (see maturity tiers); final release admission remains outstanding. The separately deployedrullst-labs-runnercandidate passed source admission in PR #228 and was then removed from the workspace for 13.0 (maintainer decision of 30 September 2026); its source remains in git history. Applications bring their own runner through the controller contract. This document is not evidence that Rullst can safely execute untrusted code in production.
Rullst should make interactive programming exercises, deterministic graders and
bounded learning games easier to build without placing untrusted code inside an
application server. The design deliberately separates the trusted control plane
(rullst-labs) from the untrusted execution plane, which is now owned and
operated by the application.
Package and deployment boundaries
rullst-labs is an implementation candidate in the Rullst monorepo, first
published in the 13.0.0-alpha.1 prerelease.
It must remain opt-in and must not become a default dependency of rullst.
rullst-labs
rullst-labs is a safe Rust library for the trusted application side. The first
candidate implements Exercise, ExecutionLimits, Submission, worker/receipt
contracts and an optional encrypted shared-local SQLite job plane. Broader
profile-independent contracts remain roadmap work. Its responsibilities are:
- versioned exercise, bounded execution, request, receipt and grading types;
- tenant-, learner- and submission-bound authorization inputs;
- resource, language, toolchain, network and artifact policy validation;
- idempotent submission orchestration, cancellation and result reconciliation;
- static-dispatch backend traits and a deterministic offline mock; and
- minimized audit events that exclude submitted source and secrets by default.
It must never interpret raw shell strings, spawn learner code, grant execution permissions or treat an AI-generated assessment as authoritative evidence.
Application-owned runner
The runner is a separately deployed program owned and operated by the
application for the untrusted execution plane. Its trusted controller uses the
rullst-labs job plane (claim_next, lease_status, complete,
abandon_attempt, cleanup_candidates, reconcile_cleanup) and signs
receipts; its isolated worker receives only bounded WorkerInput data. The
controller contract states the exact obligations and
the non-executing byo_runner_controller example shows the controller flow.
The runner’s responsibilities are:
- leased, cancellable work with stale-attempt fencing and recovery;
- pinned images and toolchains, disposable workspaces and capped artifacts;
- actually enforced and observed isolation and resource limits;
- exact, bounded worker outcomes for the trusted grader; and
- signed, privacy-minimized execution and cleanup receipts.
The runner must never be embedded in the HTTP application process. The application must not mount a Docker, Podman, containerd or Kubernetes control socket merely to operate it. Deployment credentials, host secrets and cloud metadata must be absent from the execution environment.
Removed rullst-labs-runner candidate
The workspace contained an experimental rullst-labs-runner: a Linux x86-64
controller that compiled the fixed Rust 1.96.0 profile to Wasm and interpreted
it with Wasmi 2.0.0 under delegated cgroups v2, unprivileged namespaces,
seccomp and separate Landlock domains. Its named Linux journey and
workspace/archive source admission passed in PR #228. On 30 September 2026 the
maintainer decided to remove it from the 13.0 workspace rather than publish it,
consistent with the maintenance-scope assessment of
its very high security and operational burden. Its independent isolation review
never took place, and it was never published. The source remains in git
history, and the first-profile record preserves its
design and evidence as a reference for application-owned runners.
Isolation baseline
Every real backend must apply defence in depth rather than treating a container as a complete security boundary:
- deny network access by default; explicit destinations require a reviewed per-lab allowlist;
- run rootless and unprivileged, drop capabilities, set
no_new_privs, and use a read-only root filesystem with a fresh bounded workspace; - enforce CPU, wall-clock, memory, process, file, disk, output and artifact limits, preferably with cgroups v2 on Linux;
- use namespaces plus supported seccomp, Landlock or equivalent platform restrictions;
- pin immutable image digests, compiler versions, dependencies and grader revisions;
- tear down each workload completely and quarantine abnormal results; and
- never expose other tenants’ jobs, caches, network namespaces or artifacts.
“Sandboxed” may be used in product documentation only for a named backend whose threat model, platform requirements and executable adversarial evidence are published. No backend may promise that arbitrary hostile code is risk-free.
Backend progression
These phases now describe what an application-owned runner must demonstrate; Rullst does not ship a backend for any of them.
| Phase | Scope | Required evidence before promotion |
|---|---|---|
| 0 | Protocol, threat model, ADR, policy validation and offline mock | Contract, abuse-case and compatibility tests; no untrusted execution claim |
| 1 | Restricted Wasm exercises; the protocol’s first profile names pinned Rust compilation and Wasmi in a separate restricted Linux process | Compiler and interpreter resource bounds, filesystem, environment, network, fuel/memory, output and recovery tested; WASI/component capabilities need their own later profile |
| 2 | Rootless OCI backend for workloads that require native toolchains | Pinned runtime choice, kernel hardening, resource-exhaustion and escape regressions |
| 3 | Optional microVM or independently operated cloud sandbox tier | External operational evidence, image provenance, isolation review and incident procedures |
The first-profile decision selects a bounded Rust
pure-function journey; native Rullst server exercises remain outside it.
Additional languages need a new rullst-labs protocol profile reusing the same
policy and receipt contracts instead of adding ad-hoc shell execution paths.
Learning and game scope
The foundation may support compilation exercises, unit and property-based graders, lint challenges, bounded command-line programs and server-authoritative learning games. AI may explain a result or suggest the next exercise, but it must not weaken an execution policy, grant network access, reveal secrets or convert an unverified run into a passing grade.
General-purpose realtime games still require application-specific gameplay,
assets, moderation, anti-cheat, state synchronization and operational design.
rullst-labs is not intended to become a graphics or physics engine.
Offensive security and CTF boundary
Ordinary secure-coding exercises may run under the standard restricted profile. A narrowly scoped offensive exercise may be explored only through a distinct, experimental profile with a dedicated target and deny-by-default egress.
A complete offensive CTF arena is external deployment infrastructure integrated with Rullst, not an in-process framework feature. It requires separate network segments and accounts, disposable machines or hardened workloads, vulnerable targets created solely for the exercise, rate and abuse controls, monitoring, cleanup, incident response and applicable legal authorization. It must not share hosts, clusters, databases, credentials or control planes with the Rullst application or other production workloads.
Rullst may provide typed provisioning contracts, challenge metadata, identity, scorekeeping and receipts for such an arena. Operators remain responsible for deploying and governing the isolated infrastructure. A local crate alone cannot make a full offensive CTF safe.
Acceptance gates
Before rullst-labs with any runner can be described as production-ready for
untrusted code, the runner’s operator must:
- approve a threat model and architecture decision record;
- obtain an independent security review of the deployed isolation backend;
- test fork/process, memory, CPU, disk and output exhaustion;
- test path traversal, symlink races, environment leakage and artifact escapes;
- test network denial, cloud-metadata denial and cross-tenant replay/isolation;
- test cancellation, worker loss, duplicate delivery and bounded recovery;
- publish the supported Linux/runtime matrix and fail closed elsewhere; and
- define retention, deletion, observability and incident-response contracts.
Explicit non-goals
- a claim that arbitrary code can be executed with zero escape risk;
- embedding an executor or container-control socket in the web server;
- a hosted Rullst service being implied by the open-source packages;
- a malware-analysis environment or unrestricted offensive range;
- letting an LLM decide security policy or authoritative grading; and
- store, certification or platform guarantees that require external evidence.
v13 usable journey target
Historical. This target guided the removed runner candidate. Since its
removal, the execution and isolation parts of steps 3 and 5 belong to the
application-owned runner; the contract, grading, cancellation, recovery and
retention parts remain in rullst-labs.
On September 20 the owner prioritized completing Bunny Stream and then Labs before opening more optional implementation streams. Phase 0 remains necessary for architecture and protocol review, but contracts and a mock are an intermediate milestone. The intended Labs outcome is a usable exercise and grading journey through a separately deployed runner:
- An authorized instructor defines a versioned exercise, exact toolchain/grader revision and execution policy; tenant and learner permissions are checked by the trusted application before submission and result access.
- An authorized learner submits bounded input to a durable, idempotent job. Retries cannot silently create a new execution or apply a result twice.
- One named isolated backend executes the supported exercise profile with explicit resource, filesystem, environment, network and output limits. The initial candidate is the Phase 1 Wasm backend; supported Rust exercise and compilation requirements must be demonstrated before advertising a language pack. Rust/Rullst native server exercises are not implied by Wasm support.
- The runner returns an integrity-bound receipt with exact grader revision, outcomes and bounded diagnostics. Validate request/result binding and reject stale, cross-tenant, forged or incompatible results before applying them.
- Cancellation, timeout, duplicate delivery, worker restart/loss, cleanup and reconciliation have executable recovery tests. Retention and deletion cover source, workspaces, outputs and artifacts rather than only the job record.
- A focused application consumer exercises submission, status, cancellation and result access. Package/deployment guides explain installation, supported platforms, capacity limits, required configuration and failure recovery.
Use real automated disposable-runner tests for execution and isolation; no manual or paid provider account is required to develop those fixtures. Local disk/build limits still apply, so use hosted acceptance for large runtime builds. Passing protocol mocks alone cannot establish isolation. The production-ready criteria above, including independent review of the selected backend, remain distinct requirements; automated tests do not substitute for that evidence.
The September 23 feature freeze and September 24–26 validation/publication window applied to this target. Pursue this outcome before other optional features, but do not promise the full runner before its acceptance evidence exists. If incomplete at freeze, retain an explicit experimental boundary and carry the missing journey forward. Additional languages, OCI/microVM backends and full CTF infrastructure do not block completion of a supported first profile and must not be bundled into its claim.
v13 delivery checklist
Checked items describe implemented candidate work, not stable publication or production readiness. Broader schemas/backends retain their own acceptance requirements.
- Record the Phase 0 threat model and first-profile architecture decision.
- Implement bounded versioned exercise/request/worker/receipt types and decoding for the selected profile.
- Implement policy validation and an explicit simulation that cannot produce real execution evidence.
- Implement application-authorized shared-local SQLite transport, encrypted content, idempotency, fenced leases and cancellation.
- Exercise the pinned Rust-to-Wasm/Wasmi Linux profile against the named hosted execution and denial fixtures (removed runner candidate, PR #228).
- Exercise tenant/learner denial, minimized results, terminal source removal and bounded retention.
- Pass workspace/platform and extracted-package source admission (PR #228).
- Remove the runner candidate from the 13.0 workspace; document the bring-your-own-runner controller contract with a non-executing example controller.
- Complete final release/package admission of
rullst-labs. - Application-owned runners: independent isolation review of each deployed backend before any production hostile-code claim.
- Evaluate rootless OCI or microVM profiles as separate later protocol work.
- Add native Rust/Rullst application, general Cargo, test and lint language packs; the pure-function profile does not provide them.
- Reconsider offensive CTF infrastructure only through its separately governed experimental deployment and review.
rullst-labs is in the release-order manifest and has shipped since
13.0.0-alpha.1 (maintainer decision, 8 October 2026). It packages contracts
only; no runner or execution engine is published with it. The versioned protocol keeps applications independent of any particular
runner implementation.
Labs runner contract: bring your own runner
Status:
rullst-labsis a v13 candidate, first published in the 13.0.0-alpha.1 prerelease. The experimentalrullst-labs-runnercandidate (a Linux Rust-to-Wasm/Wasmi executor) was removed from the workspace for 13.0. Its source remains in git history, for example atmainrevisionf7909033. Rullst no longer ships an executor. Applications deploy their own runner against the contract below.
rullst-labs owns trusted exercises, authorization, encrypted durable jobs,
fenced leases, exact grading, cancellation, recovery and retention. It never
compiles, interprets or spawns learner code. A runner is the application’s
separately deployed program that takes leased work, executes it in an isolated
environment and reports an Ed25519-signed receipt. This page states what that
runner must do with the existing rullst-labs API, what the library verifies and
what the host must guarantee. Nothing here certifies a runner as safe.
Roles
| Component | Holds | Never holds |
|---|---|---|
| Application (web process) | SqliteLabs handle, content key, pinned receipt public key; register_exercise, submit, get_job, cancel, set_exercise_enabled, expire_queued, purge_terminal, remove_exercise | Receipt signing seed, an executor, a container control socket |
| Trusted controller (your runner’s trusted half) | The same job database file and content key, the receipt signing seed; claim_next, lease_status, complete, abandon_attempt, cleanup_candidates, reconcile_cleanup | Application identity/session credentials |
| Untrusted worker (your isolated execution) | One WorkerInput: source, case inputs, limits and an opaque binding | Job database, content key, signing seed, expected answers, application secrets, network, control sockets |
The controller operations are not student or HTTP capabilities. Never wire them to an application route. The shared-local store means one trusted local filesystem with explicit process separation; it is not a remote broker or a network-filesystem database.
Controller prerequisites
- Depend on
rullst-labswith thesqliteandreceipt-signingfeatures. - Open the application’s store with
SqliteLabs::open(path, config, key, clock).StoreConfig(namespace,max_jobs,max_exercises, profile and anylearner_jobsortenant_exercisesquota) and theContentKeymust be identical to the application’s. The store binds them; any difference returnsConfiguration. - Use
ExecutionProfile::LinuxExperimental { tools, receipt_key }. It is the only profile whose signed receiptscompleteandreconcile_cleanupaccept;SimulationreturnsUnsupportedthere. Despite the name,rullst-labschecks neither Linux nor isolation.ToolIdentityholds seven SHA-256 digests (runner,compiler,wasm_toolchain,runtime,launcher,syscall_policy,filesystem_policy) that the operator pins for the runner’s installed files. They are bound into every job, not verified. - Create the signer with
ReceiptSigner::from_seedfrom a dedicated random 32-byte seed (all zeroes is refused). Itspublic_key()is the profile’sreceipt_key. Never reuse the content key or an application secret as the seed, and never give the seed to the application or the worker. Keep both secrets in owned private regular files under protected ancestors, and validate the opened no-follow descriptor and exact 32-byte length. - The profile, including tool digests and receipt key, is part of the store’s configuration binding. Rotating the key or changing a pinned tool therefore requires a new store: drain and retire the old one first.
- Use a clock synchronized with the store’s. The store refuses time that moves
backwards (
Clock) and receipts outside the lease window (Protocol).
Lifecycle
1. Recover before claiming
On startup, and periodically, call cleanup_candidates(limit) (1–32). It
returns leased jobs that are no longer Running (cancelled, abandoned with
cleanup pending, or expired) and Running jobs whose lease or job lifetime
ran out. A still-Running record is fenced to Uncertain (or Expired) before
it is returned. Withdrawing an exercise changes no job record: the controller
that owns a lease sees it as Stop from lease_status (and complete returns
Denied), while a withdrawn job whose controller was lost is returned only
once its lease runs out, at most the wall limit plus 15 seconds after the claim.
For each CleanupJob, tear down everything your runner owns for
binding.nonce, then attest it as described in step 7. Recovery never releases
source and can never award a grade.
2. Claim
claim_next() returns at most one LeasedJob, or None. It skips up to 32
queued jobs that are withdrawn (marked Cancelled), too close to expiry or out
of attempts (marked Expired). A job is claimed only while more than its wall
limit plus 5 seconds of lifetime remain, and at most twice. Each claim creates a
fresh random nonce and a lease that ends at the claim time plus the wall limit
plus 15 seconds, capped at the job’s expiry. LeasedJob exposes scope(),
id(), revision(), expires_at() (the lease end) and input().
3. Hand the worker its input
job.input() is a WorkerInput:
| Field | Bound |
|---|---|
binding() | AttemptBinding: request, profile and source digests plus the attempt nonce |
source() | RustSource, at most 32 KiB; expose_source() is for the isolated boundary only, never logs |
inputs() | 1–64 [i64; 2] case inputs, in grader order |
limits() | wall_seconds 5–60, fuel_per_case 1,000–1,000,000, memory_pages 32–256 (2–16 MiB) |
It carries no tenant or learner identity and no expected answers. Its JSON form
is decoded by WorkerInput::from_bytes, which refuses more than 131,072 bytes.
Protocol version 1 (PROTOCOL_VERSION) defines one profile, PROFILE
(rust-function-wasm-v1): a pure solve(i64, i64) -> i64 function evaluated
once per input. TOOLCHAIN and INTERPRETER name the removed candidate’s
Rust 1.96.0 wasm32-unknown-unknown and Wasmi 2.0.0 semantics; all three
constants are bound into exercise and profile digests. Another language or
native profile needs a new rullst-labs protocol, not a runner-side extension.
4. Monitor the lease
Before releasing source, and while the worker runs, call lease_status(&job).
Active requires an enabled exercise, a Running job, the same revision, lease
and binding, and an unexpired lease. Stop or an error means stop now. A status
read never extends the lease. Enforce limits().wall_seconds() yourself. The
removed candidate polled every 200 ms; choose an interval that bounds wasted work.
5. Validate the worker’s reply
Decode untrusted worker bytes with WorkerOutput::from_bytes (at most 20,480
bytes) and require output.binding == *job.input().binding(). Outcomes:
Executed { artifact, cases }: oneCaseOutputper input, in order. Each isValue(i64)orTrap(Fuel | Memory | Stack | Guest). A case count different from the exercise’s isProtocol.artifactis an opaque digest.CompileRejected { diagnostics }:Diagnostictext of at most 8,192 bytes, no control characters except newline and tab. It is untrusted and can quote source; escape it when rendering.Rejected(failure):Compile,InvalidModule,ResourceLimit,WorkerProtocol,WorkerLostorIsolation.
The worker never sends expected values or a pass flag. Sign Executed only for
code that actually ran: complete grades it against the hidden answers.
6. Sign and report
Build an ExecutionReceipt and sign it with ReceiptSigner::sign:
started_at: sampled afterclaim_nextreturns; it must not precede the claim time the store recorded.finished_at: at most 90 seconds afterstarted_at, not later than the store’s time whencompleteruns and strictly beforejob.expires_at().observation_digest: a digest of your actual per-job isolation and resource observations.rullst-labsstores it as evidence but cannot interpret it.teardown:Confirmedonly after the worker group and workspace are gone.completerefusesUncertain.
sign validates the receipt first: a wall clock that steps back between the
two samples makes it return Protocol. Handle a failed clock read or signing
like any other failure (step 7) instead of returning with the lease still live.
Send it to complete(job.scope(), job.id(), &signed). Executed becomes a
Completed job with per-case Passed/WrongAnswer/Trapped feedback; any
other outcome becomes Failed. Serialized receipts are decoded by
SignedReceipt::from_bytes (at most 20,480 bytes, lowercase hex signature). An
exact replay of an accepted receipt returns the same view; a different receipt
for a finished job is Conflict.
7. Abandon, clean up and reconcile
On any failure, stop signal, timeout or rejected complete:
abandon_attempt(&job)fences the attempt immediately (RunningbecomesUncertain, orExpired) and returns aCleanupJob. An old nonce cannot fence a newer attempt.- Tear down everything owned for
cleanup.binding.nonce. If absence cannot be confirmed, sign nothing; the job stays fenced withcleanup_pendingandcleanup_candidatesreturns it on a later pass. - Sign a cleanup receipt whose output is
Rejected(ExecutionFailure::WorkerLost)forcleanup.binding, withTeardown::Confirmed, whatever the attempt did before teardown. - Call
reconcile_cleanup(scope, id, &receipt, retry). Cancelled and expired jobs stay terminal. AnUncertainjob becomesQueuedagain only withretry = true, an enabled exercise, fewer than two attempts and enough lifetime (a fresh nonce on the next claim); otherwiseCancelled, orExpiredwhen too little time remains. Reconciliation never grades.
A WorkerLost output sent to complete instead becomes a terminal Failed
job without a retry. The removed candidate never retried automatically.
8. Retention
complete and terminal reconciliation erase the encrypted source and grader
snapshot from the job record. The application schedules expire_queued and
purge_terminal (at least 24 hours) with ManageJobs authorization; neither
requires the runner or claims worker teardown. The runner must delete its own
workspaces, artifacts, worker output and logs, and must not persist source or
diagnostics beyond the attempt. Backups, WAL pages and physical erasure remain
operator responsibilities.
Verified by rullst-labs versus guaranteed by the host
rullst-labs verifies | The host and its runner must guarantee |
|---|---|
Ed25519 signature against the pinned receipt_key, never a key inside the receipt | Custody of the signing seed; only the trusted controller can sign |
| Binding to the current lease: request, profile and source digests and nonce, plus job revision | That the worker actually executed that source under that profile |
| Lease and job expiry, receipt timing window, exercise still enabled | Clock synchronization; enforcing the wall limit and stopping on Stop |
Teardown::Confirmed is present | That teardown really happened before signing |
| Case count, bounded wire sizes and decoded invariants | Fuel, memory, process, disk, output and network limits actually enforced |
| Exact grading against stored expected answers; minimized public feedback | Expected answers, keys and the database never reach the worker |
| Idempotent replay, single award and fencing of stale, cancelled or withdrawn results | Real isolation evidence behind observation_digest; tool provenance behind ToolIdentity |
| Encrypted, integrity-checked job content and erasure at terminal states | Deletion of runner-side workspaces, artifacts, logs and backups |
Isolation the host owns
Treat a container, a separate crate or a separate process alone as insufficient. The minimum baseline, from the roadmap:
- run the worker outside the web application process, preferably on a separate host or VM, with no job database, content key, signing seed, expected answers, application credentials, inherited environment, cloud metadata access or container/orchestrator control socket;
- deny network access; run unprivileged with no new privileges, dropped capabilities and a read-only root plus a fresh, bounded, disposable workspace;
- enforce CPU, wall-clock, memory, process, file, disk and output limits (cgroups v2 or an equivalent) covering compilation as well as execution;
- apply syscall and filesystem restrictions and observe them in effect before releasing source; refuse work on unsupported hosts with no weaker fallback;
- pin toolchains and images by digest, tear down each attempt completely and treat compiler output and worker bytes as untrusted.
The removed candidate’s first-profile threat model records one detailed Linux design (namespaces, cgroups v2, seccomp, Landlock) and its historical hosted evidence. It is a reference, not shipped software. Describe a runner as sandboxed only with a published threat model, platform requirements and adversarial evidence for that runner, plus independent review before any production claim about hostile code.
Minimal non-executing example
rullst-labs/examples/byo_runner_controller.rs
opens the store as a separate controller, recovers leftover leases, claims a
job, polls lease_status under the wall limit, signs a fixed
Rejected(Isolation) verdict and submits it to complete. It also fences
failures through abandon_attempt and reconcile_cleanup. It never compiles or
runs learner code; two PLUG-IN POINT functions mark where an isolated worker
and its teardown belong.
cargo run -p rullst-labs --example byo_runner_controller --features sqlite,receipt-signing
cargo test -p rullst-labs --all-features --example byo_runner_controller
The course_app example
shows the application side: registration, submission, status, cancellation,
withdrawal and retention.
Compatibility
There is one wire protocol (version 1) and one profile. All wire types reject
unknown fields. A runner must use these exact types; there is no adapter layer
or runner version negotiation, and interchangeable external executors are not
provided. New profiles, languages or a remote job transport are rullst-labs
roadmap work and require their own threat model and acceptance evidence.
Labs first execution profile: decision and threat model
Historical record. The
rullst-labs-runnerimplementation described here was removed from the workspace for 13.0 (maintainer decision of 30 September 2026); its source remains in git history, and its CI jobs and helper scripts were removed with it.rullst-labsstill defines therust-function-wasm-v1profile, but Rullst no longer ships an executor: applications bring their own runner through the controller contract. Read the requirements below as a reference baseline for such a runner, not as shipped or currently tested software.
Status: implementation decision for an unpublished experimental candidate. The named Linux journey has hosted evidence below; independent security review and production readiness are not claimed. The September 23 freeze and September 24–26 validation window applied when this decision was recorded. Contracts and an offline simulation are intermediate work.
Recorded Linux acceptance
On September 21, run 35582398251
passed at 977e40a31393b6403c960919b9ea72c54813ef5a on disposable Ubuntu 24.04
with the pinned Rust 1.96 profile. Its minimized artifact records 26 checks:
actual isolation, compiler descriptor denial, shared kernel group capacity,
compilation/grading, ownership/idempotency, wrong answers, fuel/stack/memory
boundaries, compiler errors, filesystem/environment/import/output denial, fresh
guest state, compiler deadline/cleanup, cancellation, controller loss, recovery at full group capacity,
competing controllers and terminal source removal. Separate real Landlock and
seccomp regressions also passed. The same run repeated all 26 checks with
instrumentation and generated the scoped controller coverage report. Both
ordinary and instrumented artifacts preserve the named profile and outstanding
independent-review boundary. No live provider account was used.
The compiler-deadline case observes a real compiler before requiring the controller’s own five-second deadline to cancel the attempt and remove its entire group; neither an application cancellation nor a test-owned kill can satisfy it. Excess compiler output must produce the bounded resource-limit outcome, and retained diagnostics may not exceed the protocol byte limit.
PR #228 subsequently passed source
admission on the same head and merged into v13 at 278a115b:
- Native CI passed all 24 test shards across Linux, macOS and Windows, strict Clippy, formatting, database contracts and the evidence scorecard. All 43 branch-required checks passed; CodeQL reported no open alerts on the PR.
- The actual installed-archive job
audited 21 archives, exercised extracted Media/Labs contracts and the
independent application consumer, installed CLI
13.0.0-alpha.1and compiled all six blueprints. The application consumer has no executor dependency. - Combined coverage passed both exact 90% floors: 101,088/112,148 whole-repository lines (90.1380%) and 72,607/79,958 framework-library lines (90.8064%). The PR checkout tree equals the candidate’s tree.
Codecov’s separate, non-required patch check still reports 83.45936% against its 90% target, with 525 changed lines unmeasured. Compiler, worker and probe paths account for much of that gap: their sandbox counters deliberately are not exported. Passing isolated acceptance does not establish which missing lines ran. Preserve this quality limitation for follow-up without weakening isolation, excluding production paths or claiming that every check passed.
This evidence applies to that source revision and named host/profile. The final release campaign and independent isolation review remain separate requirements. It does not prove every adversarial case or every deployment environment.
Coverage must also measure the trusted controller’s lifecycle paths. The
instrumented fixture retains identical controller/worker executable hashes and
all namespace, environment, filesystem and resource checks. A CI-only LLVM hook
initializes profile output only for a trusted host with an explicit output path;
in the worker’s cleared environment it performs no profiling initialization.
Only trusted host processes write profiler data outside the sandbox; the worker
does not receive profiling variables or a host output directory. These counters
are merged with ordinary workspace coverage, without changing either 90% floor.
The scoped manual labs-isolation measurement diagnoses that path and cannot
replace the complete coverage or release campaign.
Supported journey
An authorized instructor creates an immutable exercise revision with a bounded exact grader. A currently authorized learner submits Rust source to a dedicated durable job. A separately deployed runner compiles and evaluates that submission under one named Linux profile. The application exposes authorized status, result and cancellation, with durable restart/lease recovery and bounded retention.
The first exercise is a pure function with the fixed ABI
solve(i64, i64) -> i64, evaluated over at most 64 instructor-defined cases.
Rust 1.96.0, the wasm32-unknown-unknown standard-library/toolchain files and
the runner artifact and minimal dynamic runtime tree must be pinned and checked. Submission compilation uses
fixed arguments and no Cargo build scripts, package downloads, external
dependencies, arbitrary shell commands or student-selected toolchain paths.
Rust source, compiler output and resulting Wasm are all untrusted bounded data.
Native Rullst applications, arbitrary-language workspaces and network exercises
remain later profiles rather than implicit capabilities of this one.
Wasmi 2.0.0 is selected for the first interpreter implementation: validation,
extra runtime checks, explicit structural/store/stack limits and deterministic
fuel are required; WAT parsing, WASI, host imports, shared memories, threads,
start functions and unsupported proposals are excluded. A fresh instance per
test case avoids hidden state between cases. Reference-type decoding is enabled
for the pinned Rust standard library’s call_indirect encoding; tables are
restricted to bounded funcref, and imports remain prohibited. The interpreter is one boundary
inside process isolation, not a substitute for it. Wasmtime/component/WASI
support remains a separate profile decision.
Ownership and process boundaries
| Component | Authority and data |
|---|---|
Application and rullst-labs | Current tenant/course/enrollment policy; exercise/grader revision; durable job submission/status/cancel/result. No execution engine, learner process or control socket. |
| Dedicated shared-local job store | Versioned, integrity-checked encrypted source/grader content; bounded jobs, leases, cancellation and minimized results. Separate key and database from application identity/session state. |
| Trusted runner controller | Dedicated job-store and receipt authority; pinned tool paths; leased work, compiler/executor launch, bounded output and teardown. No general application credentials. |
| Per-job compiler/interpreter process | Only the selected source, fixed toolchain, case inputs and disposable output/workspace. No expected answers, job database, runner signing key, host home, environment credentials or control sockets. |
Shared-local means one trusted local filesystem with explicit process separation; this profile does not supply a remote broker, network filesystem or distributed runner service. Application authorization never follows from a client-provided learner, course or provider identifier. File possession/configuration is not a student capability. Neither signed requests nor an AI evaluation can weaken the execution policy or manufacture a passing result.
Content and receipt keys require private regular files owned by the controller under protected canonical ancestors. The controller validates the opened descriptor, rejects symlinks and non-regular files without blocking on a FIFO, and requires exactly 32 bytes. File permissions alone do not protect a key whose parent directory lets another local user replace it.
Mandatory Linux execution boundary
The implementation must apply and test all of the following before accepting untrusted input into the worker:
- A dedicated delegated cgroups v2 subtree for each job, with hard memory,
swap, process and CPU limits plus controller-owned wall-clock deadlines.
Place the inert child in its bounded group before releasing input; cover
compiler initialization/translation as well as guest execution.
The delegated root must have a kernel-enforced
cgroup.max.descendantsbetween 1 and 32, including probe groups. Counting directories before creation cannot enforce capacity across concurrent controllers. Reconcile authenticated expired/cancelled attempts before preflight so a full subtree can recover; cleanup never releases source and cannot award a successful grade. - Unprivileged user, PID, mount, IPC and network namespaces; capability drop, no-new-privileges, no inherited terminal/session or additional user namespaces. The launcher is a reviewed, pinned configuration of Bubblewrap, not an arbitrary command string supplied by an application request.
- A minimal read-only toolchain/runtime tree, a fresh bounded workspace and explicit descriptors. No host home, D-Bus/container sockets, job-store mount, writable toolchain/cache, application files or metadata endpoint reachability. Tool files/directories and their canonical ancestors must be owned by root or the dedicated controller UID. Shared-writable ancestors are refused except sticky parents protecting the next owned component; tool trees themselves must never be shared-writable. Host administrators/controller ownership remain trusted, and digest checks do not replace trusted installation or custody.
- Before source is released, require fully enforced Landlock filesystem
restrictions (ABI v3 rights) for fixed tools, runtime files and the bounded
workspace. Spawn the trusted compiler helper before the interpreter applies
Landlock, then restrict each in a separate domain. The helper checks its own
actual OS boundary and denied access to its parent’s descriptors/memory before
acknowledging readiness. Only then may the interpreter receive source. Regular
/procand cgroup files are denied by the filesystem policy; the kernel does not mediate a process’s own anonymous pipes through/proc, so cross-process protection must use Landlock’s ptrace/domain hierarchy. Compiler stdin/stdout become null at exec; only capped stderr diagnostics return. Inherited descriptors are checked against the exact owned protocol handles. - Reviewed syscall restrictions in addition to namespaces; no extra privileges
or unbounded resource allowance on unsupported kernels/platforms.
The compiler selects the pinned linker by its absolute toolchain path. Rust’s
changed-
PATHspawn fallback uses a socket pair, so a bare linker name would fail under the unchanged no-sockets policy. A real seccomp regression verifies absolute-path spawning while socket-pair creation remains denied. Rust’s captured linker streams also requireioctl(FIONBIO)to toggle nonblocking reads on existing descriptors. The filter permits that exact request only; it does not grant device-control ioctls or socket creation. A real filter regression captures both child streams and verifies another ioctl request is still denied. The namespace exposes no device-control handles. - Structural Wasm validation, bounded compilation, memory/stack/table/fuel limits, no guest imports and bounded, normalized output. No deserialization of untrusted native/precompiled engine caches.
- Mandatory teardown of the entire job group and workspace. Uncertain cleanup, memory/process exhaustion, malformed output and worker loss cannot be graded as successful execution.
Runtime probes must check actual restrictions, including namespace separation,
denied filesystem/network/control-socket access, capability state and effective
cgroup limits. Property names, process exit zero or a launcher version alone are
not admission evidence. The development machine accepted a user-service
PrivateNetwork request while retaining the same network namespace; a subsequent
namespace launch failed. Those probes are evidence of an unsupported environment,
not permission to remove network restrictions or execute learner code there.
Automated acceptance must provision a supported disposable Linux host explicitly.
The Ubuntu 24.04 acceptance host needs an AppArmor namespace permission for its
trusted launcher. The hosted test provisions a root-owned private copy of the
distro Bubblewrap executable and a separately named, checksum-reviewed copy of
Ubuntu’s bwrap-userns-restrict profile, attached only to that launcher path.
Its child profile denies capabilities. This setup is confined to the disposable
host; it never disables AppArmor or changes a global namespace sysctl. The runner
still requires its own namespace, capability, seccomp, Landlock and cgroup probes.
Ordinary workstations are not reconfigured by the runner or local test suite.
The domain split addresses the kernel’s documented special-filesystem limitation. A failed denial probe blocks source release; removing that probe without a replacement boundary is not an acceptable compatibility fix.
Integrity, grading and recovery
An immutable request binds protocol/profile version, tenant, course, learner, exercise/grader revision, source digest, pinned tools, resource policy and local submission ID. Reusing an ID with different content fails. Database content is authenticated before use and bound to indexed identity/configuration. Source and expected answers do not appear in Debug, routine events, metric labels or errors.
Claiming a job creates a bounded durable lease and execution nonce before launch.
Worker output contains bounded case values/traps, never authoritative expected
answers or a caller-controlled passed=true. Trusted grading uses the exact
stored exercise revision. Result acceptance binds the current lease/request and
refuses stale, expired, cancelled, cross-tenant or incompatible receipts. A
replayed receipt cannot award credit twice. Compiler diagnostics are bounded,
treated as untrusted text and escaped for any HTML/terminal presentation.
Cancellation records denial before process termination and fences any late result. On restart, reconcile persisted work and owned job groups; do not assume that a missing connection or process means compilation/execution succeeded. Pure functions permit deliberate bounded re-execution after confirmed teardown; there is no exactly-once external side-effect claim. Retention covers encrypted source, workspaces, artifacts, diagnostics and result/idempotency tombstones. Restored state needs clock/configuration/lease reconciliation, not silent reuse of stale authority. Operators own encrypted backups and physical storage erasure. Authorized application maintenance expires queued source independently of runner availability, without touching leased work. A withdrawn grader can be removed only after all referencing job records have passed retention and been purged. Removed immutable revision identifiers must not be reused.
Adversarial acceptance required
The executable test plan includes malformed/oversized source and Wasm, incompatible
ABI/imports, infinite loops, recursion, memory/table/output/disk/process exhaustion,
compiler denial of service, filesystem traversal/symlinks, environment and file
descriptor leakage, network/metadata/control-socket denial, concurrency, worker
restart/loss, stale-result fencing, cancellation/expiry, idempotency collisions,
retention and actual application submission/status/result access. Include both
working Rust examples and failing submissions; a mocked receipt cannot pass the
execution/isolation gate.
Memory acceptance separately verifies denied growth, out-of-bounds access and
Rust allocation failure. An allocator abort is a generic guest trap because a
Wasm unreachable instruction does not reliably identify its original cause;
it must not be relabeled as a memory-specific trap merely to fit a test.
The platform matrix and named profile remain experimental until the required tests and independent isolation review exist. No claim of risk-free hostile-code execution, complete Rust/Rullst language support or generic secure CTF hosting follows from the contracts or this decision.
Primary implementation references
- Wasmi 2.0 API and feature boundaries
- Wasmi configuration and validation defaults
- Bubblewrap policy responsibilities and limitations
- Wasmtime security boundaries, including compilation denial of service
- Linux cgroups v2
- Landlock Rust bindings and enforcement status
- Linux seccomp filter
- Rust 1.96 captured process output
- Rust 1.96 Linux nonblocking descriptor operation
Reviewed September 20, 2026. Runtime/toolchain updates require fresh policy and compatibility review; benchmark claims from runtime projects are not Rullst performance or isolation evidence.
Managed private video candidate
On September 20, 2026 the owner asked about Bunny.net video hosting and a native
Rullst integration. The rullst-media candidate now implements the
selected Bunny Stream lifecycle with opt-in HTTP and shared-local SQLite, an
authenticated browser acceptance consumer and an executable offline example.
It remains outside the framework facade. Its standalone package is now included
in the v13 release inventory and was first published in the 13.0.0-alpha.1
prerelease; the final release campaign remains outstanding, and real-provider
interoperability remains unvalidated.
PR #227 passed workspace/platform
checks and the actual installed-archive job
at b21d52e7692ffceec689223a04658a95156778a0, then merged into v13 at
618655c1 on September 21. The final release campaign remains separate; real
Bunny interoperability has not been tested. The LMS example’s media fields do
not enable this service.
A Bunny Stream integration is selected for implementation after the deployment diagnostic and supervision observation base. The owner’s subsequent September 20 direction prioritizes completing the supported private-video lifecycle before starting Labs or another optional integration. This is an acceptance target, not a promise that every Bunny service/API belongs in v13. Final release acceptance remains required; since September 22 the owner no longer requires publication by September 26 (see the delivery plan).
Supported candidate journey
- An authorized instructor creates a tenant/course-bound asset record. Resolve ownership on the server; caller-supplied library/video IDs cannot authorize upload, playback, modification or deletion.
- Grant a scoped, expiring upload capability without exposing account/library management keys to the browser. The TUS client bounds bytes/requests/time and handles resume, pause and cancellation. Provider tokens do not prove one-shot use or enforce these client limits against a modified client.
- Track processing through authenticated notifications and authoritative provider reads. Verify exact raw-body signatures in constant time. The current Bunny webhook v1 signature does not bind a timestamp: duplicate/reordered or replayed notifications cannot independently authorize publication or regress current state.
- Check current membership and course entitlement before issuing a short-lived playback token. Permission withdrawal stops new grants; a previously issued provider bearer token may remain usable until expiry. Do not advertise it as inherently bound to a Rullst login session or immediately revocable.
- Exercise a real HTTP/browser consumer, unavailable/deleted assets, expiry, tenant/owner denial and replay/failure cases. Keep captions/transcripts and keyboard accessibility explicit. A mock or signed URL unit test alone cannot establish real provider playback acceptance.
Completion target before another optional integration
The first release target is a usable private course-video integration, with the complete supported lifecycle and its recovery paths:
- Persist tenant/course ownership, provider asset identity and versioned local lifecycle state. Provide authorized creation, bounded listing/status reads, metadata updates, publication/withdrawal and deletion with reconciliation.
- Authorize resumable uploads without browser access to library management secrets; handle expiry, retry, cancellation and ambiguous remote outcomes. Record the provider limits actually enforced instead of promising unsupported restrictions. Reconcile creation that may already have succeeded remotely before retrying it.
- Process authenticated notifications with durable deduplication and explicit authoritative refresh. Cover duplicates, replay, reordering, failed processing and missed notifications without silently publishing or regressing assets.
- Issue and renew short-lived playback grants only after current entitlement and asset-state checks. Document required embed and direct-file/CDN settings, expiry after withdrawal, unavailable/deleted assets and key/configuration changes; do not claim instantaneous invalidation of issued bearer tokens.
- Exercise the lifecycle in a focused authenticated application/browser consumer. Include visible processing/failure states, keyboard-accessible playback controls and a documented captions/transcripts integration path.
- Deliver typed errors, bounded request/response/deadline behavior, redacted diagnostics, deterministic offline mode, setup and operations documentation, and packaged-consumer tests. Production must explicitly reject mock grants.
The implementation must first settle and record its SST/storage/API decisions. Automated HTTP fixtures must test the selected provider protocol and failure paths; browser tests must exercise the actual application authorization and player lifecycle with controlled media. Those tests cannot establish real Bunny transcoding or playback compatibility. The owner’s no-live-account-testing direction below remains in force and must appear in the delivered status.
Paid DRM, additional video providers and other Bunny products remain separate scope. They do not prevent completion of this private-course-video profile. Conversely, a token helper and mocked success response alone do not complete it.
Architecture and provider boundary
The SST selects rullst-media for managed-video lifecycle responsibilities,
separate from Core’s default runtime and object-storage helpers. Reuse Auth,
tenant context and existing application entitlements through explicit composition.
Start with one provider and support declared capabilities instead of pretending
all video services have interchangeable APIs.
Embed-view tokens protect the provider player entry point. CDN/file tokens, direct playback/original-file exposure, allowed domains, CSP/COEP and DRM are distinct controls and must be reviewed together. Domain restrictions are not student authentication. No integration can promise prevention of every copy, recording or redistribution. Enterprise DRM needs its own provider configuration, fees and device/license acceptance; it is not implied by ordinary signed URLs.
The owner confirmed an existing Bunny account and Stream library on September 20, then explicitly requested no manual or real-provider testing during this implementation window. Do not use that account/library, create paid resources, upload user media or change live settings. Continue automated tests with local protocol fixtures, disposable infrastructure and simulated provider failures. Empty/mock credentials need deterministic offline behavior; production cannot use that mock as playback authorization. Document real-provider interoperability as unvalidated and keep the initial integration opt-in with a precise supported scope. Later application fixes should be contributed back to the framework.
Primary references reviewed on September 20
- Bunny Stream pricing: storage counts generated renditions and optional retained files; delivery tier and viewer region affect the bill. Basic token integration does not include paid DRM.
- Embed view authentication: server-generated video/expiry signatures and separately enabled library policy.
- Signed processing webhooks: exact body, version/algorithm headers and library read-only API key; no signed timestamp.
Implementation also reviewed the official Stream OpenAPI, TUS and advanced CDN HMAC documentation. See the candidate integration and operation guide for exact support, recovery, provider configuration and acceptance limitations. This candidate is not a claim that Bunny is universally cheapest or that real Bunny interoperability has been validated.
Verus verification pilot for v13
Status: locally verified v13 candidate, 20 September 2026. One production method predicate and three deliberate failing controls have passed the pilot harness. A manual workflow is prepared; hosted acceptance and any promotion remain pending. There are no new runtime dependencies. It adds no release gate and preserves the existing Kani, Miri, fuzzing, integration and provider acceptance requirements.
The first property uses the existing AgePolicy::permits implementation. Expand to
Auth and Capital only after measuring compatibility, proof maintenance and CI
cost. The first privacy/age journey retains its product priority.
Implemented candidate and measured boundary
The source-linked projection verifies exactly this engineering policy table:
| Risk preset | Self-declaration | Facial estimation | Verified attribute |
|---|---|---|---|
| Low | Allowed method | Allowed method | Allowed method |
| Elevated | Denied method | Allowed method | Allowed method |
| Restricted | Denied method | Denied method | Allowed method |
An allowed method does not authorize an application action or establish a
person’s age. The predicate receives an AgePolicy and an AgeMethod; it does
not inspect evidence, signatures, challenges, replay state, clocks or guardians.
Those boundaries retain their runtime/integration tests and separate contracts.
The specification lives in rullst-privacy/verification/age-policy.spec.rs.
An ordinary CLI integration test parses actual production Rust with syn, checks
the package/root/module/export path and the exact reviewed enum/type/signature
domain, and extracts the executable body unchanged. It rejects new calls,
macros, unsafe code or conditional attributes until their proof closure is
reviewed. Type/body/source/specification fingerprints accompany the projection.
The only additions are Verus annotations, Structural support for the existing
fieldless derived-equality enums, a closed specification accessor and an erased
reveal step. Production source and normal package dependencies remain unchanged.
There is no handwritten copy of the decision algorithm and no proof precondition
that excludes one of the nine risk/method combinations.
The trusted boundary includes the syntax extractor, Rust’s derived structural
equality, Verus/its proof library, Rust compiler, Z3 and the executing host.
--no-cheating rejects local assume, admitted properties and external bodies;
the imported verifier standard library remains part of the trusted toolchain.
The runner requires the whole projection, exactly one verified production
function and its actual successful result. The three negative implementations
weaken restricted/elevated policies or deny low-risk methods. Each must fail
that function’s postcondition; an absent proof, compiler error, zero-test pass
or changed tool/source cannot substitute for a functioning negative control.
The pinned Linux bundle is Verus 0.2026.09.13.671956e, upstream commit
671956ec527d3b7164779f767bdbfe769bedce6c, Rust 1.98.1 and Z3 4.16.0.
The archive digest, flags and inventory are in .github/verus-toolchain.json.
Every run checks the archive and all extracted members before executing them.
Ordinary application builds still use the framework’s own stable/MSRV policy
and do not install Verus.
Two initial local runs took 12.105 and 12.139 seconds including full bundle validation. In the first, the production proof took 0.82 seconds and each negative control about 0.53–0.54 seconds; peak prover RSS stayed below 292 MiB. The compressed tool download is about 486 MB and expands to 1.63 GB. These are measurements on one workstation, not hosted-CI or cold-download predictions. Execution uses one verifier thread, a 60-second wall limit per case, 45-second CPU limit, 2 GiB address-space limit and 2 MiB output-file limits. Local runtime age tests and a Rust 1.96 age-feature check also passed.
Running the isolated pilot
On Linux, download the exact URL from .github/verus-toolchain.json into a
disposable directory, install the pinned Rust toolchain and use fresh absolute
output paths. The installer verifies the archive before extraction:
python3 .github/verus-pilot.py install --archive /absolute/release.zip --output /absolute/tools
RULLST_VERUS_OUTPUT=/absolute/projection cargo test -p cargo-rullst --test verus_policy_linkage --locked
python3 .github/verus-pilot.py verify --archive /absolute/release.zip --bundle /absolute/tools/verus-x86-linux --projection /absolute/projection --output /absolute/evidence
evidence.json records source SHA, dirty-worktree status, source/spec/type/body
linkage, exact tool identities/flags, result hashes, wall/CPU time and peak RSS.
Dirty local evidence is explicitly developmental. The prepared verus.yml
workflow uploads the projection and reports for its exact selected commit;
availability through GitHub’s manual workflow registry and a successful hosted
run must be established before citing hosted evidence. It remains outside the
required release workflow inventory.
Subsequent scope, not proven by this pilot
| Module | Proposed property | Explicit boundary |
|---|---|---|
rullst-privacy::age_assurance | A restricted policy permits only a verified age attribute; expired, inconclusive or unavailable evidence cannot authorize an action requiring valid evidence. Threshold arithmetic stays within validated bounds. | Server time, authenticated subject/tenant binding, issuer trust, signature verification and atomic durable replay claims must have explicit contracts. Facial accuracy, document authenticity, guardianship and legal compliance are separate evidence. |
rullst-auth | The selected authorization predicate rejects expired sessions and revoked session versions, given authoritative revocation state. | State freshness, persistence, races, cryptographic implementations and application routing are outside a pure predicate proof unless separately specified and verified. |
rullst-capital | Selected integer money calculations respect currency scale, amount limits and the documented rounding rule; unrepresentable results return a typed error without overflow or silent truncation. | Provider prices, taxes, exchange rates, webhook delivery, distributed effects and live payment acceptance remain external or integration contracts. |
Begin with the existing age-policy method and threshold decisions, then the evidence decision boundary. Identify the exact Auth and Capital functions before expanding the inventory. Review preconditions against public input validation; excluding a difficult valid input is not a fix for a failed proof.
Code and package placement
Keep specifications and proof support adjacent to the owning module. Verify the executable functions compiled into the library; any separate abstract model needs a checked connection to those functions before it can support a production claim. A passing copy of an algorithm is only model evidence.
No new public framework crate is planned solely for Verus. The adoption review must choose and test how annotations, proof support and any required macros fit the existing packages. Keep the verifier toolchain isolated and validate stable Rust/MSRV, feature combinations and packaged consumer builds before accepting dependency or source changes. Ordinary application builds must not require installing the verifier.
Every proof records its property, executable entry points, valid input domain,
external contracts and trusted assumptions. Review assume, external bodies,
axioms and unverified dependencies explicitly. An unfinished proof or an
assumed target property cannot count as successful verification. Preserve
runtime validation at boundaries reached from unverified callers.
CI adoption and promotion
The candidate dedicated verus.yml workflow uses workflow_dispatch and
a small named proof inventory. Its checks fail on a proof failure, timeout,
missing proof or tool error; the pilot remains outside release admission until
promotion is explicitly implemented.
- Pin the verifier, compatible Rust toolchain, proof libraries and solver; verify downloaded artifacts and retain their identities.
- Use bounded concurrency, per-proof time/resource limits and cancellation of superseded runs. Measure cold/warm duration, memory, disk and maintenance cost before expanding the matrix.
- Retain the exact source SHA, proof inventory, assumptions, dependency/tool versions, flags and results. Cached builds never substitute for checking the candidate’s proofs.
- Exercise representative incorrect implementations to show that the intended properties fail verification. Retain ordinary behavioral and integration tests for the surrounding application boundary.
- Promote only after repeated reproducible runs, reviewed assumptions, production-code linkage, consumer compatibility and an agreed CI budget. Assign maintenance ownership and document the tool-upgrade procedure.
- After promotion, require verification for changes to the selected code, specifications, relevant callers/contracts, transitive dependencies, locks or verification tooling. Unknown impact must trigger the complete proof inventory. Required-check wiring must not leave skipped checks indefinitely pending or accept a missing run as proof.
Delivery checkpoints
- Select the first age-policy method predicate and review its threat model, contracts, assumptions and compatibility with a pinned Verus version.
- Implement source-linked local proof and negative controls, stable runtime tests and an MSRV check without changing production dependencies.
- Prepare the manual workflow and local resource/evidence collection.
- Obtain clean exact-commit hosted evidence and the combined package-consumer rehearsal; the local dirty-worktree samples are not release admission.
- Review results and decide whether to make the selected scope mandatory on relevant v13 changes.
- Evaluate the Auth and Capital predicates independently before expanding the scope or any release requirement.
A result supports only the named properties under their documented assumptions. It does not establish that the whole framework is formally verified, that an age estimate is accurate, or that a deployment complies with privacy law.
References
- Verus project and supported-language boundary
- Specifications, assumptions and external bodies
- Rullst privacy and age-assurance delivery plan
- Current Rullst verification contract
SaaS findings: v12.1 maintenance and v13 contracts
v13 update (8 October 2026): this triage is a historical record. The v13 line later removed the Paddle, Lemon Squeezy, Polar, Razorpay, Mercado Pago, Alipay, Coinbase Commerce, PicPay and Wise adapters and the NFS-e module; see the migration guide. Findings about those adapters apply to the 12.x line only. It also removed the SendGrid, Postmark, Mailjet, Mailtrap and Azure Communication Services mail transports (including the ACS Managed Identity transport mentioned below); see the migration row “Mail providers removed”. The 12.x line reached end of life on 16 October 2026 (12.3.0 is its last release), so the v12.1 maintenance targets below are closed.
2026-09-18 account-mail and follow-up candidate
The latest examples account-lifecycle proposal adds MAIL-001/RULLST-005. The current change set fixes reset-link preparation and CLI plain-text output, adds localized typed lifecycle templates, an opt-in PostgreSQL/SQLite account registry with atomic reset/session revocation and encrypted outbox, a facade worker, native Resend idempotency/verified feedback and ACS Managed Identity. See the exact adoption and remaining proposal scope. Redis recovery, other identity-action transactions, marketing consent and an audited administrative resend interface remain separate work; they are not silently counted as implemented by the mail template fix.
RULLST-004 now has exact authenticated machine-route CSRF exceptions while retaining browser CSRF, WAF and headers. SAAS-013 now checks fifteen isolated ORM/Core/Studio/Nexus/facade driver graphs. SAAS-014 has an additive one-time Stripe SDK contract with price/ownership/amount/mode binding, reconciled paid/refunded/disputed receipts and verified notification hints. Generated subscription code stays recurring; one-time application persistence and new provider sandbox acceptance are still required. Production SaaS sales are identified as real money in the README, separately from staging.
These are candidate-source changes. Historical mainline CI and sandbox records below do not certify this revision. Full workspace all-feature testing, strict Clippy, formatting and release admission must pass on the candidate commit.
Status: SAAS-002, SAAS-003 and SAAS-004 have typed replacements in maintenance
source. Stripe test-mode API, hosted Checkout and CLI-relayed signed events
passed at 0f15d70a; generated SQLite controller/webhook persistence passed at
370a4170. Final candidate CI remains in progress after correcting a
test-only dependency in the no-default build; SemVer, coverage and CodeQL passed.
Other provider accounts,
publication and candidate deployment remain unverified.
The input is the two reports from Rullst/examples, branch
feat/saas-staging-ai-fixes, pinned to commit
d6094dbd67d12ee965ce73dfb539d3ada9b4195a:
Those reports inspect published 12.0.0. This review compares their findings
with framework source at de77430ccd86c847c98d67002f3478e57637180f on v13.
The table records that initial source review. The implementation notes below
track subsequent corrections. The initial review was inspection, not a fresh
Windows build, resolved feature-graph test, provider sandbox run, or examination
of either deployed SaaS site. Application workarounds do not fix the packages.
The follow-up report is pinned to branch fix/saas-staging-healthcheck-waf,
commit c49d6ee8f1b8df79dca8e9255a87b1a322fdc08b:
rullst-errors.md, RULLST-003.
The report attributes an HTTPS reproduction to the examples team; this review
independently confirms the source cause and exercises a local production router.
The additional fix/saas-nexus-trusted-tls report at
ac9a6b0625b703de8ca84d0bd10906395f500033
adds APP-SAAS-002: the application omitted Nexus’s explicit trusted-TLS
capability behind Azure ingress. The existing framework correctly rejects
Basic authentication without that evidence; forwarding headers alone must not
enable it. The report retains SAAS-001–015 and adds no new framework defect.
The subsequent report is on examples/main, pinned to
1af10ed4da60a5e101c9f0dd20b2c823f38ac5ee:
- APP-SAAS-003: a rejected duplicate checkout consumed the application’s
new-session limit, and the owner could not resume an already-created session.
The report describes a correction in the example’s application orchestration,
not the framework limiter. Generated Stripe billing now persists owner-bound
attempts and retrieves an existing open session before creating another. It
distinguishes complete, expired and unknown outcomes and tests retries and
lost responses. Applications adding a new-session quota must count newly
persisted sessions, return
Retry-Afteron quota rejection, and keep any all-request abuse limit separate. - APP-SAAS-004: Chromium blocked the local POST’s hosted Checkout redirect
under
form-action 'self'. The maintenance SaaS generator now adds onlyhttps://checkout.stripe.comto its own CSP, matching its explicit default provider. Core’s default is unchanged. Other providers need an exact reviewed store/custom origin;make:billingexplains this without overwriting existing policy. Server-side URL/session ownership validation remains mandatory. Browser regression covers generated policy rendered by Core with a synthetic POST/303 and intercepted destinations. It is not deployed SaaS, authentication, provider, payment or full generated-application acceptance.
Both integration requirements carry into v13. The report’s correction claims are attributed to the examples repository; no deployed site or live session was tested here. The CSP rule is described in the W3C form-action contract.
Completed SAAS-003/004 implementation
Maintenance commit 8c3c8391 and its v13 carry d44f9520 provide:
- Polar’s current product checkout, external customer identity, sandbox endpoint, optional typed trusted client IP, response/return-placeholder binding, and signed subscription matching. Legacy price-only callers migrate explicitly.
- Generated Stripe customer/checkout intents and session IDs, account/mode scope, signed Checkout and subscription events without email ownership, ID-bound portal, current-state reconciliation, database revision fencing and atomic event/subscription commits on SQLx and Turso. Unknown old outcomes have bounded read recovery and an explicit verified customer-recovery function for operators.
- Local evidence: 171 Capital tests with Actix; 341 CLI library tests; ten structural contracts; strict Clippy; materialized SQLite/Turso compilation, migrations, lost-response/replay/rollback, cross-owner and stale-read rejection, replacement subscriptions and process restart. Full candidate CI remains distinct from this targeted evidence and from live provider-account testing.
Final maintenance consolidation
The Stripe/Polar source at 8c3c8391 passed
SemVer for all sixteen packages,
coverage and
CodeQL.
The documentation-only follow-up 4b5aca21 passed the
full 48-job Rust matrix.
These runs do not cover the subsequent Paddle implementation or substitute for
the required exact-main release gates.
SAAS-002 now has typed Paddle customer and recurring transaction creation, approved payment-page binding, explicit sandbox selection, read-only recovery, transaction-bound signed subscription events and current-state reconciliation. Cancellation and pause use the selected API and validate the returned change. Local Capital tests and strict all-target Clippy passed; protocol fixtures are not evidence of an approved Paddle account or a completed sandbox payment.
The Paddle source at e7f63a2e passed
SemVer for all sixteen packages,
line and branch coverage workflows
and CodeQL, with no
open branch alerts observed. Line coverage reached 90.1240% repository-wide
(86,711/96,213) and 90.6740% for framework libraries (62,838/69,301).
The test-server dev-dependency repair at 0f15d70a passed 154 no-default-feature
Capital tests and strict Clippy; the
full candidate Rust matrix
remains the separate cross-platform gate. Subsequent release-preparation changes
to documentation and workflow validation still require exact-main admission.
Reports on examples/main at 6c3e371cc48eae9ba0311b42155f2cf3ff8b0cb4
retain the same framework findings and add APP-SAAS-005–011, covering application
ingress, seller disclosure, OIDC/resource permissions, payment-mode presentation
and backup/restore operations. Their reported corrections belong to the example
and infrastructure. They introduce no additional framework defect.
The staging health endpoint answered successfully, and the examples repository’s deployment run passed its authenticated Chromium Stripe handoff. That app uses 12.0 dependencies and an application-owned one-time payment flow. It does not validate 12.1’s recurring subscription contract, final payment or webhook delivery.
On 18 September, a separate candidate executable built from a4c07bfa used the
staging account’s test key without deploying or changing the SaaS. Two bounded
runs passed: API-only acceptance and API plus signed events delivered through
the official Stripe CLI 1.51.0 relay. They exercised account/mode binding,
customer and checkout idempotent retries, authorized reads, rejection of a
foreign owner, expired-checkout recovery, current trial/canceled subscription
reads, and signed Checkout expiration/subscription creation/deletion events.
Signature verification used the relay’s actual secret and original payload;
test events were not locally fabricated or resigned. All synthetic customers,
sessions, subscriptions and catalog objects were deleted, closed or archived.
No credentials, contact data or hosted checkout URLs are retained in this book.
The subsequent browser run at 0f15d70a also completed hosted Checkout in
Chrome using Stripe’s official test card. Provider reads confirmed a paid test
Checkout and invoice matching the expected BRL 100 minor units, customer and
subscription. The candidate verified the actual signed Checkout completion and
subscription creation/deletion deliveries, read active state, canceled the test
subscription and confirmed canceled state. Customer deletion, terminal session
state and archived catalog responses were checked. See the
redacted acceptance record.
This proves the listed adapter operations against Stripe test mode, including
simulated payment completion. It does not prove delivery to the deployed
application’s webhook. A subsequent isolated run at 370a4170 generated fresh
SQLite billing modules through the candidate CLI and checked them against its
templates without modifying the billing implementation. The generated controller
persisted customer/checkout intents before payment, returned HTTP 303, and
resumed the same open session in a fresh process. Hosted Checkout completed in
test mode, and provider reads confirmed the expected customer, BRL 100 minor
units and paid invoice.
The genuine signed Checkout completion and subscription creation/deletion events passed through the generated webhook router. Fresh processes read the matching active and canceled subscription rows and durable event receipts. Replaying each delivery left persisted state unchanged; altered signed bytes returned HTTP 400 without changing state or receipts. All fifteen checks and cleanup operations passed; see the generated SQLite acceptance record. The harness supplied a synthetic trusted owner identity and used an isolated local database. It did not exercise deployed SaaS ingress, login/session authentication, or that deployment’s database. Turso has separate materialized domain tests. Polar/Paddle account acceptance also remains separate; no credentials for those providers were available here. The 12.0 application deployment was unchanged.
For current behavior use the v13 migration guide and the Capital provider matrix; the 12.1 migration guide records the 12.1 changes, and the chronological containment notes below are historical.
Implementation checkpoints
The maintenance candidate is PR #206. None of these checkpoints means the complete release or a live-provider journey has passed. Pending items in older checkpoint descriptions describe that historical point; the completed implementation above supersedes their SAAS-003/004 containment:
20218538: configured Lemon Squeezy store/variant binding; bounded Stripe rotated-signature verification; explicit unsupported Paddle/Polar checkout, Wise email transfer and generated live portals; generated HTTP 303 handoffs, committed application lockfiles and supported MSVC flags.9976da5b: shared AI provider configuration, including explicit Groq and custom OpenAI-compatible endpoints, for client and Nexus.8b7761a2: RULLST-003 removes generic clients from the default User-Agent blocklist. Local production-router tests retain configurable crawler denial, CSRF, payload limits and secure headers. Existing explicit configuration is not rewritten. Core library: 238 tests passed; strict default-feature Clippy for all Core targets passed. This is not an all-feature workspace result.efb9518b: Razorpay lifecycle normalization and signed-event negatives; 127 default-feature Capital tests and strict Clippy for all targets passed.- Materialized billing tests pass for SQLite and Turso, including 303 redirects, cross-owner denial and unavailable live portals before database access. The customer/subscription pair now commits atomically; fault injection in either table proves rollback and a successful handler retry on both backends. Stable provider customer binding, checkout idempotency, provider namespaces and atomic inbox/domain state still need implementation and acceptance.
- Additive
StripeCheckoutRequest/create_subscription_checkoutbinds an existing customer, local reference, recurring price, redirects and retry key; validates the returned session and line item; and distinguishes local mocks. All 132 default-feature Capital tests and strict all-target Clippy passed. This is protocol/local evidence; the generated checkout still needs durable customer provisioning, attempt persistence and the verified event flow. - Standalone ORM consumers can disable defaults and enable exactly one strict
SQLx backend. Generated CRUD and transaction methods passed strict Clippy
on all three profiles; their normal/build graphs contained only the selected
SQLx driver. The default
drivers-allprofile retains existing convenience. Facade/Studio compositions still need their own opt-in isolation boundary. - The follow-up full CI run at
e1d8fe4dexposed unconditional enum codecs in backend-exclusive builds. Runtime-gated codecs fix the no-driver all-target check, and the expanded standalone consumers now compile enum bindings and decoding for PostgreSQL, MySQL and SQLite without importing other drivers. These targeted checks do not supersede the next full candidate matrix. - Stripe subscription normalization now reads Basil item-level periods and rejects missing/confused event kinds, identities, states and price items. Legacy subscription-level periods and custom plan IDs remain supported; contradictory periods fail. Three real-HMAC fixture tests cover those branches; 139 Capital tests with Actix enabled and strict all-target Clippy passed. Subscription state is still not invoice-settlement evidence.
- The additive verified Stripe event envelope retains event/scope metadata, the exact provider status and separate mutation/payload digests without a pre-handler replay claim. Four signed-envelope contracts cover tampering, scope/mode confusion, mutation identity, delivery variation and explicit mock rejection. All 143 Capital tests with Actix and strict Clippy passed locally. The generated handler has not yet switched to a durable atomic inbox.
- Additive Stripe customer provisioning binds opaque owner metadata and an immutable retry request, with optional contact email and validated response mode/identity. Five protocol/negative/mock contracts pass; all 148 Capital tests with Actix and strict all-target Clippy pass. This supplies the provider operation, not durable intent, account/owner binding or generated integration.
SqlStripeEventInboxatomically retains a verified event’s scoped identity, mutation digest and outcome with caller-supplied domain SQL. SQLite tests cover restart and injected inbox-write failure; shared PostgreSQL, MySQL and MariaDB contracts cover retries, conflicting content, eight concurrent deliveries, domain rollback, cancellation, immutable capacity and scope/mock denial. These pass with both Any and the matching native ORM pools. This is database/protocol evidence; customer/attempt persistence, event ordering and replacement of the generated handler remain required.- Subscription retrieval now binds the current Stripe response to a persisted customer/owner/price/subscription/mode and preserves its exact provider state. Four protocol, negative and mock contracts pass; all 152 Capital tests with Actix and strict all-target Clippy pass. The read must still be serialized with its domain update; it is not invoice settlement or event ordering.
- The SaaS blueprint now reuses the shared billing models, removing a raw PostgreSQL placeholder from subscription lookup. All ten scaffold contracts, the four materialized foundation applications and CLI all-feature/all-target strict Clippy pass locally; this is not provider sandbox acceptance.
- New SaaS/
make:billingroutes now contain the unfinished live flow: real or mixed credentials return HTTP 503 before provider I/O, replay claims or SQL. Offline development fixtures remain available. Existing applications require a reviewed controller migration; new typed provider/inbox APIs do not replace their owner binding automatically. Full live integration remains required before enabling that generated operation, as permitted by the P0 allocation.
All eleven v12 adapters are in scope: Stripe, Lemon Squeezy, InfinitePay, Polar, Paddle, Razorpay, Mercado Pago, Coinbase Commerce, PicPay, Alipay and Wise. Wise is a payout adapter; the others implement the billing trait. Test each operation independently and record explicit unsupported boundaries. A passing mock or a failure-before-dispatch test is not sandbox acceptance. The 12.1 SaaS generator selected only Stripe or Lemon Squeezy (the v13 SaaS starter uses Stripe); extending that selector requires provider-specific configuration and lifecycle contracts.
Release allocation
P0 below means necessary before enabling the affected live operation, not that every provider must become live before a framework maintenance release. An explicit unsupported operation is acceptable containment; a malformed request or fabricated success is not. Important compatible fixes can ship separately on the v12 maintenance line without waiting for all v13 capabilities.
| Finding | Evidence in reviewed baseline | v12.1 maintenance target | v13 target / acceptance |
|---|---|---|---|
| SAAS-001 · P0 Lemon Squeezy store | providers/lemonsqueezy.rs still sends store 1. | Add validated store configuration without removing the existing constructor; an unconfigured real request must fail explicitly. | Bind response to store/variant, protocol fixtures and official sandbox evidence. |
| SAAS-002 · P0 Paddle checkout | providers/paddle.rs sends nested customer.email and top-level return_url. | Correct the supported flow if it fits the contract; otherwise disable that live operation with a typed error and accurate matrix. | Typed transaction/customer/checkout boundaries, approved payment-link prerequisites and usable sandbox checkout evidence. |
| SAAS-003 · P0 Polar checkout | providers/polar.rs uses product_price_id and /v1/checkouts/custom/. | Contain the obsolete path; do not silently reinterpret an existing price ID as a product ID. | Current products-based API, local-subject binding and reviewed webhook lifecycle. Any client IP forwarding requires a trusted proxy boundary and a stated purpose. |
| SAAS-004 · P0 Stripe ownership | providers/stripe.rs lacks a local subject in checkout; the generated handler requires webhook email. | Add an opt-in typed checkout/receipt path and safe generator flow, or keep the affected live scaffold disabled. Do not claim an email lookup solves ownership. | Persist attempt/session/customer/subscription bindings; accept the relevant signed lifecycle events; test missing email, changed email and cross-owner/tenant denial. |
| SAAS-005 · P0 POST redirect | billing_controller.rs.template uses Redirect::temporary. | Emit HTTP 303 after POST; assert status and Location in a materialized route test. Review the portal handoff too. | Preserve browser method semantics in every checkout flow. |
| SAAS-006 · P0 Portal capability | Scaffold mounts a portal backed by a live UnsupportedOperation. | Expose an honest unavailable state and capability-gate route generation; no invented customer portal. | Provider-specific sessions bound to persisted customer identity, with their own sandbox tests. |
| SAAS-007 · P0 Wise transfer | providers/wise.rs constructs recipient/quote/idempotency values from email/profile/amount. | Reject the unsupported live flow before dispatch; preserve deterministic offline behavior. | Separate recipient, quote, transfer and funding operations; UUID idempotency, corridor rules and reconciliation. Transfer creation is not funding confirmation. |
| SAAS-008 · P0 Checkout idempotency | Generic checkout has no durable attempt/idempotency input. | Add an optional typed API without adding required methods to downstream trait implementations; legacy ambiguity must be documented and contained. | Persist attempt before dispatch, bind key to request digest, forward it only under a reviewed provider contract and reconcile unknown outcomes. |
| SAAS-009 · P0 for multiple gateways Provider namespace | Generated customer/subscription schemas omit provider; uniqueness is email/subscription ID only. | Keep the supported scope explicit and provide a reviewed opt-in migration; never guess the provider for existing rows. | Namespace by provider account and test/live mode as well as provider, with authenticated tenant/subject ownership; cover ID collisions and provider migration. |
| SAAS-010 · P0 Atomic billing state | Generated customer and subscription saves are separate. | Couple event processing and billing mutation in a transaction; add the event-envelope boundary needed to do so compatibly. | Durable inbox identity/digest/outcome, transactionally updated entitlements and outbox effects; crash, cancellation, duplicate, out-of-order and retry tests. |
| SAAS-011 · P1 MSVC flags | project/env_config.rs still emits /DEBUG:FASTLINK. | Remove the unconditional unsupported flag and validate generated Windows builds. The reported LNK4315 was not reproduced here. | Capability-tested linker choices without unmeasured performance claims. |
| SAAS-012 · P1 Reproducible applications | Generator ignores /Cargo.lock; Docker build omits --locked. | Retain the binary application’s lockfile and require it in deployment builds, including dependency-cooking stages. Document initial lockfile generation. | Generate/build/package from one committed lockfile; test missing/stale lockfile failures. |
| SAAS-013 · P1 Strict backend graph | ORM unconditionally enables SQLx SQLite/PostgreSQL/MySQL/Any drivers; its own default and strict-* features are empty. Studio also requires queue-sqlite. | Audit all consumers and offer a backend-exclusive profile with migration guidance. Preserve existing default convenience deliberately rather than breaking it accidentally. | Explicit Studio queue feature and standalone PostgreSQL consumer graph excluding unrelated SQLx drivers. Confirm with cargo tree; a workspace --all-features graph is not an isolation test. |
| SAAS-014 · P1 One-time checkout | Stripe checkout always uses mode=subscription; charge() is a different off-session contract. | Describe the existing method as subscription-only; add an opt-in one-time API only with full evidence. Never use a recurring plan for an advertised one-time purchase. | Explicit payment/subscription modes, server-owned prices, success/cancel URLs, durable receipts and the correct paid/refunded/disputed event sets. |
| SAAS-015 · P0 Stripe key rotation | Verifier overwrites each earlier v1 signature. | Bound header/candidate sizes and accept any valid candidate with constant-time HMAC verification, one valid timestamp and freshness enforcement. | Test valid first/middle/last, malformed neighbors, duplicate timestamps, all-invalid, stale and oversized headers; preserve the fix. |
| RULLST-001 · P1 Nexus AI configuration | Nexus detection and AiClient::auto() both omit Groq configuration. | Add an explicit application-supplied client path or one consistent resolver, with documented precedence. | Reuse configuration across authorized panels; distinguish offline, configured and unavailable. Test Groq-only configuration, failure handling, authorization and safe rendering. |
| RULLST-003 · P1 Machine-client WAF denial | Default blocklist rejects curl, Wget, Python and Go regardless of request intent. | Remove those defaults and preserve request inspection and configurable crawler policy; test the production baseline. | Treat User-Agent as a forgeable traffic preference, never authentication or authorization. |
At the reviewed baseline, provider files above were under rullst-capital/src/.
The shared billing template is under cargo-rullst/src/generators/ and feeds
both SaaS and make:billing; fixes must cover both. The SaaS schema was assembled by
cargo-rullst/src/blueprints/saas/models.rs from the templates under
cargo-rullst/src/blueprints/saas/src/, with separate SQLx and Turso
generator templates that also require review.
The Nexus resolver mismatch is broader than Groq: detection advertises
OPENAI_BASE_URL while AiClient::auto() does not consume it, and the latter
consumes DEEPSEEK_API_KEY while the panel detector does not. One source of
configuration should replace this drift. An environment variable is never
connectivity evidence.
Additional framework review: Razorpay lifecycle
The legacy adapter maps subscription.authenticated and payment.captured
to an active subscription and can substitute an order ID for a subscription ID.
The maintenance correction requires the subscription entity’s own bounded
identities and matching lifecycle state. Authentication, standalone payments,
missing/confused identities and mismatched states fail instead of granting
access. Supported activation/charged/resumed and pending/halted/paused/cancelled
fixtures are signed with real HMACs. No live account was used; event ordering,
durable processing and provider/customer/tenant binding remain host work.
Razorpay documents distinct states
and subscription events.
Compatible maintenance boundaries
Paddle review found the same last-signature-only pattern as Stripe, plus
duplicate-timestamp acceptance. Its verifier now accepts any matching bounded
h1 candidate and requires exactly one timestamp. Two real-HMAC regressions
failed before the correction and pass afterward, together with all 159 Capital
tests under Actix and strict all-target Clippy. The existing configured
freshness window remains in force. Paddle’s
signature contract
allows multiple h1 values; this correction does not imply live checkout or
subscription-lifecycle acceptance.
Polar review reproduced discarded RFC3339 billing periods/current customer contacts and acceptance of unrelated event kinds or conflicting identities. A bounded subscription parser now separates lifecycle events from orders, preserves current and unambiguous legacy fields, and distinguishes scheduled cancellation from final revocation. Three signed regressions failed before the fix; all 162 Capital tests with Actix and strict all-target Clippy pass afterward. The reviewed subscription schema and event sequences define that boundary. The legacy event still omits account/mode, scheduling and ordering metadata; hosts must retain verified raw evidence and reconcile ownership/settlement. No sandbox or generated checkout is enabled by this fix.
InfinitePay’s implemented HMAC/subscription payload does not match the reviewed checkout callback contract. The live verifier and handler are now explicitly unsupported pending reviewed authentication and payment lookup bound to merchant, order and amount. Offline fixtures still require an explicit mock secret. This contains the unsupported framework path; it does not assert that the provider lacks other API products.
Additional Lemon Squeezy review found that the legacy parser accepted missing
event/object kinds, serialized absent customer/variant IDs as null, and mapped
on_trial to Unpaid. A dedicated subscription parser now validates lifecycle,
store/mode/identity and expiry, separately from invoice events. Three real-HMAC
regressions failed against the old code and pass after the correction. Provider
object
and event contracts
define the boundary; this is not a live-account test or durable reconciliation.
- Preserve source compatibility in v12.1: additive constructors, builders, extension contracts and explicit deprecations. A mandatory trait method, changed public struct literal or silently changed identifier meaning can break applications even when the function name stays the same.
- Containing an invalid live operation may change runtime behavior; document the failure and upgrade path. Do not retain fabricated values for the sake of apparent compatibility.
- Generator fixes affect newly generated code. Existing applications need reviewable source/database migrations, collision reports and rollback or forward-recovery guidance. Do not regenerate over customized code.
- A webhook replay claim alone is not atomic domain processing. The database commit must determine whether processing completed; failed work must remain recoverable. A timeout after provider mutation remains an unknown outcome.
- Test-mode and offline mocks remain useful. Production configuration must reject a mock as payment evidence; a success redirect never grants access.
- Allocation to v12.1 is a target, not evidence of merge or publication. Carry reviewed fixes into v13 and verify their presence by commit, not branch name.
Boundaries that are not framework defects
RULLST-002 is the umbrella reference to SAAS-001–015, not a sixteenth
independent payment defect. Studio’s disconnected AI playground is a documented
limitation. Adapters that explicitly reject unsupported live methods are also
documented capability boundaries, not automatically broken integrations.
The reports attribute Docker dependency-copy mistakes, assistant formatting, proxy/origin behavior, unsafe example HTML and application error disclosure to the examples repository. Keep their regression work there and contribute only reusable fixes upstream. A paid file already committed publicly cannot become exclusive through route authorization; private artifact storage is application work. No private Academy implementation or learner data is copied into this public planning document.
Delivery slices and required evidence
- Compatible containment and small regressions: SAAS-005/006/011/012/015; contain SAAS-001/002/007 until their request contracts are complete; SAAS-003 now has its typed product contract.
- One complete Stripe journey: SAAS-004/008/010 plus provider-scoped persistence from SAAS-009; opt-in SAAS-014 only when payment and subscription evidence are distinct. Test retries, refunds and disputes as well as success.
- Configuration consistency: RULLST-001 and SAAS-013, with explicit migration and feature-consumer coverage. These can proceed independently of gateways.
- v13 capabilities: finish additional providers one operation at a time, then multi-provider reconciliation, portals and Wise payouts. Retain the operation-level capability matrix and sanitized provider evidence.
Every implementation slice needs targeted negatives, materialized generator
coverage, compatible consumer checks and the repository release gates. Hosted
checks should handle cold native/all-feature matrices whose disk peak cannot be
bounded locally. Local tests must respect the disk reserve in AGENTS.md.
Provider sandbox evidence is separate from Rust tests; no live payment, payout,
deployment or credential change is part of this planning review.
Provider contracts consulted on 17 September 2026
The request discrepancies above are consistent with the provider-owned Lemon Squeezy checkout, Paddle transaction, Polar checkout and Wise transfer contracts. Stripe documents overlapping endpoint secrets and multiple signatures in its webhook guide. These references support contract review; they do not prove account eligibility or acceptance of a Rullst request.
Quality Scorecard
The published v12 source, eb11f892ae28f076e7a83c38a635316c6ed89028,
received 94/100 (A) in
Rust CI run 34895536764.
The artifact quality-scorecard-eb11f892ae28f076e7a83c38a635316c6ed89028
records all sixteen crate scores and the gates used to award them.
These are repository-owned audit scores. They measure the stated engineering criteria, not feature completeness, security certification, provider homologation or superiority over other frameworks.
How scores are awarded
The versioned audit policy sets reviewed per-crate ceilings. Rust CI constrains those values using its actual results; a green run cannot invent additional points.
| Dimension | Weight | Evidence |
|---|---|---|
| API and architecture | 20 | Explicitness, cohesion and public boundaries, constrained by compile/lint, feature and MSRV gates |
| Verification depth | 25 | Reviewed test depth, constrained by cross-platform workspace results |
| Security and failure design | 20 | Fail-closed, error and secret boundaries, constrained by lint and specialist checks |
| Documentation and DX | 15 | Reviewed guides, examples, features, migrations and evidence links |
| Operations and release | 20 | Reviewed durability, live-service and recovery contracts, constrained by relevant gates |
Failed, cancelled or skipped applicable gates suppress the dimensions they prove. A targeted diagnostic run cannot substitute for the full CI matrix. See workflow triggers and scopes for when the report is emitted.
Grades are A+ 97–100, A 90–96, B 80–89, C 70–79, D 60–69, and F below 60.
Stable v12 results
The v12 requirement was A or better for fifteen non-IoT packages and B or better for IoT. The stable-source run met it. The policy was reviewed on September 3, 2026; the later release run applied its conditioning gates rather than claiming a new independent expert audit.
| Crate | Current audited score | Grade | Principal remaining evidence boundary |
|---|---|---|---|
rullst-core | 96 | A | Dependency operations, distributed deployment and host authorization |
rullst-orm | 96 | A | Online snapshot isolation, managed/PITR backup, vendor operations and application writer/tenant/key policy |
rullst-security | 96 | A | Trusted rollback checkpoints, external SIEM delivery, independent audit and certification |
rullst-connect | 95 | A | Remote-provider leases/reconciliation, key/directory/backup operations, multi-host refresh and provider conformance |
rullst | 96 | A | Whole-file recovery/backup operations, multi-host coordination and maturity inherited from opt-in domain crates |
rullst-auth | 95 | A | Shared ceremonies, multi-host state, refresh workflow and normative WebAuthn conformance |
rullst-mail | 95 | A | Authoritative malware/CDR inspection, multi-host operations and inbox/provider evidence |
rullst-messaging | 96 | A | Remote protocols/replication, full metadata encryption and provider operations |
cargo-rullst | 95 | A | Production deployment, provider accounts and real-application acceptance |
rullst-ai | 95 | A | Exact live-model results, non-compatible streaming/provider loops, durable audit receiver operations and external retrievers |
rullst-studio | 94 | A | Durable/OTLP storage, key operations and shared operator authorization |
rullst-capital | 93 | A | Live provider-account acceptance, InfinitePay live validation and authoritative outbox/reconciliation |
rullst-orm-macros | 95 | A | Compiler/ecosystem compatibility beyond the tested matrix |
rullst-nexus | 95 | A | Host identity/domain policy, global/custom-route authorization, immutable audit delivery and production operations |
rullst-macros | 94 | A | Real browser/network ecosystems and host identity policy remain external |
rullst-iot | 83 | B | Concrete transport/hardware storage, flashing and bootloader evidence |
| Repository (equal-crate aggregate) | 94 | A | 1,509/1,600; exact score remains conditional on the SHA’s gates |
What “ceiling reached” meant
The v12 campaign reached its reviewed target of 1,509/1,600 points. A zero gap means that campaign’s target was met. It does not mean further improvement is impossible, that every roadmap item shipped, or that a model has a permanent capability ceiling.
The earlier planning tables and interim scores are preserved in the immutable pre-publication record. Future work can earn different scores only after the policy, implementation and evidence are reviewed together.
v13 privacy candidate
The 20 September policy update retains the sixteen existing ceilings and adds
rullst-privacy to the seventeen-package candidate inventory. Its initial ceiling
is 85/100, below the unchanged 90-point RC planning floor and 95-point local
target. The report remains observational; these planning values grant no release
approval and are not a published v13 result.
The reviewed dimensions are API 19/20 (explicit optional contracts), verification
23/25 (restart, concurrency, real PostgreSQL and generated HTTP consumers),
security 19/20 (bounded, bound and fail-closed decisions), documentation 14/15
(scoped methods, setup and recovery obligations), and operations 10/20. Pending
installed-distribution acceptance, first registry publication/ownership and
operator recovery evidence withhold operations credit. Live providers and
verified guardianship remain outside the implemented scope. The workspace
and generated CLI test matrix conditions security/operations credit; the local Verus pilot
is separate evidence and does not increase this report’s score. This score
predates the first publication of rullst-privacy in the 13.0.0-alpha.1
prerelease on October 11, 2026.
Maintaining the score
The September 22 package-preparation candidate adds Supervision and Media to the nineteen-package scorecard inventory while retaining every earlier ceiling. Each new package starts with the same conservative 85/100 ceiling as Privacy: API 19, verification 23, security 19, documentation 14 and operations 10. The 90-point RC planning floor and 95-point local target remain unchanged. These are reviewed policy limits, not awarded scores or independent audits.
Supervision’s evidence covers its optional domain contracts, shared-local
SQLite state and minimized browser observations; its generated LMS consumer
was removed with make:supervision. It does not verify guardianship, infer
cheating as fact or control other device applications. Media’s evidence covers the Bunny lifecycle, protocol failures,
browser upload, durable recovery and an extracted consumer; actual Bunny/CDN
interoperability and application entitlement policy remain outside that evidence.
Both withhold operations credit for ordinary-release archive acceptance, first
registry registration and deployment recovery. Their current hosted gates must
pass before the report can award the applicable dimensions.
On 8 October 2026 rullst-labs joined the release inventory and the scorecard,
making twenty packages, with the same 85/100 ceiling and dimensions. Its
evidence covers the exercise, authorization and grading contracts and the
encrypted shared-local SQLite jobs; it never executes learner code. Its
isolation acceptance was removed with the runner candidate; execution evidence
now belongs to each application-owned runner.
The four 85-point ceilings were set before these packages were first published in the 13.0.0-alpha.1 prerelease on 11 October 2026. Registry publication and ownership are no longer pending, but registration was not the only reason for the ceiling: until the policy is reviewed again it withholds the same operations credit for installed-archive acceptance and deployment recovery.
For every relevant change, retain the commit and workflow result, identify which gates and evidence changed, and report capability progress separately. New source does not inherit the stable release’s score automatically.
Provider acceptance, physical devices, store publication, independent review and production operations remain separately scoped evidence. Use the capability status, capability ledger, and release audit for those boundaries.
OpenSSF Scorecard: evidence and improvement plan
The public report
is separate from Rullst’s per-commit quality scorecard.
On 29 October 2026 at 15:37 UTC, OpenSSF Scorecard v5.6.0 reported 8.5/10
for 27440eb88706fe739bf7cc5a72908e61c45d6f9a. The README badge follows the
live report; this dated snapshot is not a promised future score or certification.
Remaining checks
| Check | Snapshot | Evidence and next action |
|---|---|---|
| Code-Review | 0/10 | The scanner found no approved changesets among 7 sampled changesets. Arrange independent human review, especially for authentication, tenant boundaries and release infrastructure. AI analysis is additional evidence, not a second human reviewer. |
| Branch-Protection | 3/10 | On main the scanner found required pull requests and status checks, administrator enforcement, stale-review dismissal and blocked force pushes and deletion. It deducts for no required approvers, no code-owner review, no last-push approval and no up-to-date requirement. Since 29 September 2026 main requires 19 checks without an up-to-date base (development-branch ruleset); v12 keeps 46 checks and an up-to-date base (maintained-branch ruleset). Required human approvals remain absent while there is one active maintainer (see the governance policy). |
| SAST | 7/10 | CodeQL is configured, but the scanner found none of the last 30 commits checked. Pull requests to main skip CodeQL, and its checks are not required there; it runs after each push to main, weekly and on pull requests to v12. |
| Vulnerabilities | 9/10 | One OSV record, GHSA-qwgh-2vcv-g2f7, covers block-buffer before 0.12.1. Cargo.lock still resolves block-buffer 0.10.4 through digest 0.10 (used by sha1, sha2 0.10 and curve25519-dalek 4). The record has no RustSec identifier, so cargo deny and cargo audit do not report it. Next action: assess whether the 0.10 line is affected and move those dependents off it when their releases allow. |
| CII-Best-Practices | 7/10 | The Silver badge, granted on 10 October 2026, is detected; Gold is the next level. |
The other thirteen checks scored 10 in this snapshot, including Signed-Releases (signed bundles and provenance on the last five releases, v12.2.0 to v13.0.0-alpha.3) and Binary-Artifacts (the Labs runner’s Wasm fixture left the repository with the runner candidate). No check is disabled and no badge score is hardcoded. An unavailable check is missing visibility, not a passing check. Making it observable can lower the aggregate score while improving the accuracy of the report.
Earlier snapshots, for comparison: on 27 September 2026 Scorecard v5.5.0
reported 8.2/10 for 6c926f95b5e6ba700ac7b094f30144c96d2b5fb7, with
Branch-Protection unavailable to the workflow token. The public rulesets made it
observable, and the follow-up run
the same day reported 7.9/10 for a8a78aa2ac79654e366930ae581cc2829922d9f4
(Branch-Protection 4/10, Signed-Releases 0/10 before the portable bundle was
published). These observations do not establish a future release score, and
badge improvement is not a publication prerequisite.
Portable release attestations
The release workflow already signs the verified crate archives, native CLI
artifacts and release evidence in a job that neither checks out source nor
executes downloaded artifacts. The added export copies that action’s original
bundle to rullst-release.sigstore.json; it does not invent, re-sign or alter
the attestation. Its unchanged signed DSSE envelope is also exported as one
JSON line in rullst-release.intoto.jsonl, for in-toto consumers. Both jobs compare
that export against the original bundle and reject a mismatch. The signing job
verifies the exported bundle before registry publication.
An immutable workflow artifact then transports it to the GitHub release job,
which independently verifies every attested file after downloading it
against the bundle, the expected repository, exact release workflow/tag identity,
source and signer commits, GitHub’s OIDC issuer, and hosted-runner requirement.
Any verification failure stops GitHub release creation.
This makes the signature, certificate and transparency evidence available beside the artifacts, including for consumers retaining local release evidence. GitHub’s attestation service remains available; the CLI’s existing verification policy is unchanged. GitHub CLI supports verification with a downloaded bundle:
# Supply an independently trusted tag and full commit, not values read only
# from an unverified download. ARTIFACT is a downloaded crate or CLI file.
gh attestation verify "$ARTIFACT" \
--bundle rullst-release.sigstore.json \
--repo Rullst/Rullst \
--cert-identity "https://github.com/Rullst/Rullst/.github/workflows/release.yml@refs/tags/$RELEASE_TAG" \
--cert-oidc-issuer https://token.actions.githubusercontent.com \
--source-ref "refs/tags/$RELEASE_TAG" \
--source-digest "$RELEASE_COMMIT" \
--signer-digest "$RELEASE_COMMIT" \
--deny-self-hosted-runners
A local bundle does not by itself supply the verifier’s trusted roots. Follow the GitHub CLI instructions when preparing completely offline verification.
Scorecard recognizes .sigstore.json release assets.
It also recognizes .intoto.jsonl provenance.
Those filename heuristics are weaker than cryptographic verification, which is why
the release job verifies the actual bundle and artifacts. The score samples up
to five releases. Releases before v12.1.2 and their attestations were not
rewritten.
Acceptance
The export first shipped with v12.1.2, and every release since (v12.2.0,
v12.3.0 and 13.0.0-alpha.1 to alpha.3) attaches both files. On 29 October 2026
the command above, with RELEASE_TAG=v13.0.0-alpha.3 and the tag’s commit
be33683252d29244ea14311790ac66decaf9d299, verified
rullst-macros-13.0.0-alpha.3.crate against that release’s bundle and rejected
a copy with one byte appended. Signed-Releases scores 10 in the current
snapshot.
A change to the release or provenance steps still needs admission of the new source under the release guide. Use the existing fuzz equivalence policy; do not assume that unchanged Rust files imply reusable evidence, alter campaign duration, or weaken a gate to save a run. Only a later public Scorecard report can establish a score change.
Independent human review is still rare: there is one active maintainer, and the successor maintainer’s approvals count only for changes they actually review. Badge declarations and actual continuity arrangements remain maintainer decisions. The official check definitions and restricted authentication guidance describe their criteria. Do not fabricate approvals or weaken protections to improve a numerical score.
bestpractices.dev granted the Silver badge on 10 October 2026, with every criterion answered. The Silver evidence guide records the answer, justification and evidence for each criterion. Keep it current when an answer changes.
OpenSSF Best Practices: Silver Evidence
Rullst holds the Silver OpenSSF Best Practices badge (project 13321), granted on 10 October 2026. This page is the evidence guide for the Silver form, with one row per Silver criterion:
- the answer to select;
- a justification ready to paste;
- the evidence link.
Only a project owner can edit the form. Keep this guide and the form in agreement when an answer changes.
The criteria list was taken from the official Silver criteria on 9 October 2026: 55 criteria (44 MUST, 10 SHOULD, 1 SUGGESTED). Several also appear in the passing form and are already answered there.
How to use it. Open the project’s Silver form, select the answer in the second column and paste the justification. Where the form requires a URL (marked “URL required”), paste the evidence link after the justification.
Every MUST criterion is met. The last one, documentation_achievements, was
met when PR #447 added the badge to
the README on 10 October 2026.
Owner confirmations recorded on 9 October 2026:
vulnerability_report_credit: the only advisory in the last 12 months (application-key configuration, fixed in 12.1.1) was opened by the maintainer from the project’s own review; no outside reporter needs credit.documentation_currentanddocumentation_roadmap:SECURITY.mdnames 12.3.0, andROADMAP.mdhas a dated twelve-month section.sites_password_security: the demo applications onrullst.winare project sites. They are built with Rullst and sign users in withrullst-auth, which hashes passwords with Argon2id; the answer is Met.
Basics
| Criterion | Answer | Justification to paste | Evidence |
|---|---|---|---|
achieve_passing | Met | Rullst holds the passing badge (project 13321). | bestpractices.dev/projects/13321 |
contribution_requirements (URL required) | Met | CONTRIBUTING.md states the requirements: tests for new functionality and regression tests for fixes, Conventional Commits, cargo fmt, strict Clippy and the full test suite, and the pull-request template. | CONTRIBUTING.md |
dco (URL required) | Unmet | Not adopted. Almost all changes come from the single maintainer, and contributors license their work under MIT through GitHub’s terms of service (inbound=outbound), which is not a per-contribution assertion. Adopting it means requiring git commit -s in CONTRIBUTING.md and a DCO check on pull requests. | CONTRIBUTING.md |
governance (URL required) | Met | GOVERNANCE.md documents who decides, how proposals are accepted, rejected or deferred, the change and release controls, and continuity. | GOVERNANCE.md |
code_of_conduct (URL required) | Met | Rullst adopts the Contributor Covenant 2.1 in the standard location. | CODE_OF_CONDUCT.md |
roles_responsibilities (URL required) | Met | GOVERNANCE.md names the maintainer and release decision maker, the successor maintainer and the contributor role, with each role’s duties and limits (including AI assistants). | GOVERNANCE.md, roles |
access_continuity (URL required) | Met | Since 8 October 2026 a successor maintainer holds GitHub organization ownership (with 2FA), release-environment approval, ownership of every crate on crates.io and recovery access to the security inbox, verified through the APIs and a test issue (#440) and re-checked yearly. | GOVERNANCE.md, continuity, successor checklist |
bus_factor (URL required) | Unmet | There is one active maintainer. The successor holds every credential needed to continue the project, but does not maintain it day to day, so fewer than two people can currently carry the work. | GOVERNANCE.md |
documentation_roadmap (URL required) | Met | ROADMAP.md describes what is planned (the v13 line and its release path, its priorities, ideas after 13.0) and what is not (abandoned milestones such as M39, work assigned to a separate programme). The v13 maintenance scope records what was removed and why. | ROADMAP.md, v13 maintenance scope |
documentation_architecture (URL required) | Met | The framework specification documents the directory conventions, crates and capabilities and the core APIs; the architecture-decisions guide and security architecture describe the main components and their boundaries. | Framework spec, architecture choices |
documentation_security (URL required) | Met | SECURITY.md, the security architecture, the security-layers guide and the claims ledger state what users can and cannot expect, including the limits of each control. | Security architecture, which layer to use, SECURITY.md |
documentation_quick_start (URL required) | Met | The README quickstart and the “Start here” and “Zero to a complete app” guides take a new user from installation to a running application. | README quickstart, Start here |
documentation_current | Met | Guide code is compiled as doctests, and every documentation change runs the book build, offline link and anchor checks on Markdown and rendered HTML, a browser smoke test and a spell check. Documentation is updated with the behavior it describes. | documentation.yml, book doctests |
documentation_achievements (URL required) | Met | The README front page shows and links the OpenSSF Best Practices badge next to the OpenSSF Scorecard badge. | README |
accessibility_best_practices | Met | Browser smoke tests of the documentation site check the skip link, ARIA states, keyboard navigation and reduced motion. cargo rullst audit --report checks image text alternatives, form labels and page language in generated applications. No formal WCAG conformance is claimed. | site-browser-smoke.mjs, audit report |
internationalization | Unmet | Documentation, CLI and generated starters are English only. The framework has a few locale hooks (account mail in English, Portuguese and Spanish; a configurable HTML lang), but no general message-catalog localization. | account mail |
sites_password_security | Met | Project sites that keep accounts are the demo applications on rullst.win; they are built with Rullst and sign users in with rullst-auth, which stores only Argon2id password hashes. The repository, releases, book and crates.io use GitHub/crates.io sign-in and store no passwords. | rullst-auth |
Change Control
| Criterion | Answer | Justification to paste | Evidence |
|---|---|---|---|
maintenance_or_update | Met | v12 received patch and minor releases on a protected branch until its end of life on 16 October 2026, with an announced migration path to v13, which develops on main. Deprecated APIs stay for at least one minor release, every major has a migration guide, and cargo rullst upgrade previews and applies upgrades. | Compatibility policy, v13 migration |
Reporting
| Criterion | Answer | Justification to paste | Evidence |
|---|---|---|---|
report_tracker | Met | Individual issues are tracked in GitHub Issues with bug and feature templates; the bug template sends security reports to private disclosure. | GitHub Issues |
vulnerability_report_credit (URL required) | Met | The vulnerabilities fixed in the last 12 months were found by the project’s own reviews, so there was no outside reporter to credit. SECURITY.md commits to crediting reporters in the changelog and advisories unless they ask for anonymity. | SECURITY.md |
vulnerability_response_process (URL required) | Met | SECURITY.md documents private reporting (email or GitHub private vulnerability reporting), what to include, response and patch targets by severity, and credit; the advisory-exceptions policy covers severity, ownership and temporary exceptions. | SECURITY.md, advisory exceptions |
Quality
| Criterion | Answer | Justification to paste | Evidence |
|---|---|---|---|
coding_standards (URL required) | Met | Rust code follows the standard Rust style (rustfmt defaults) and Clippy. CONTRIBUTING.md and AGENTS.md add project rules, such as no panics in production paths, parameterized SQL and Conventional Commits. | CONTRIBUTING.md, AGENTS.md |
coding_standards_enforced | Met | Every pull request runs cargo fmt --all -- --check and cargo clippy --workspace --all-targets --all-features -- -D warnings, plus zero-panic Clippy gates on library code. | ci.yml, zero-panics.yml |
build_standard_variables | Met | Builds use Cargo, which honors RUSTFLAGS, CARGO_ENCODED_RUSTFLAGS and linker settings. There are no first-party build scripts or Makefiles that replace them; the only native C/C++ code (optional bundled DuckDB) is compiled by the cc crate, which reads CC, CFLAGS, CXX and CXXFLAGS. | Cargo.toml |
build_preserve_debug | Met | Debug information follows Cargo profiles, and the workspace does not force stripping. It can be requested with standard settings such as CARGO_PROFILE_RELEASE_DEBUG=true; the test profile omits DWARF by default to bound disk use and honors CARGO_PROFILE_TEST_DEBUG. | Cargo.toml |
build_non_recursive | Met | One Cargo workspace resolves and builds a single dependency graph; there are no recursive Makefiles or sub-builds. | Cargo.toml |
build_repeatable | Met | Published .crate archives are bit-for-bit reproducible from the same commit and pinned toolchain. CI repackages two crates from a fresh checkout on every pull request, the release job reproduces every archive before publication, and an independent rebuild matched the crates.io checksum of rullst-macros 12.2.0. Native CLI binaries are attested but not claimed reproducible. | Reproducible crate archives |
installation_common | Met | The CLI installs with cargo install cargo-rullst --version =13.0.0-alpha.4 --locked (Cargo skips prereleases, so a plain cargo install cargo-rullst selects the end-of-life 12.x CLI) and uninstalls with cargo uninstall cargo-rullst; libraries are added as normal Cargo dependencies with the same exact version. | Getting started |
installation_standard_variables | Met | cargo install honors CARGO_INSTALL_ROOT, --root and CARGO_HOME, Cargo’s conventions for the installation location. Libraries are never installed system-wide. | Getting started |
installation_development_quick | Met | After cloning, rustup installs the toolchain pinned in rust-toolchain.toml, and cargo build and cargo test work; CONTRIBUTING.md lists the steps. | CONTRIBUTING.md, development setup |
external_dependencies (URL required) | Met | The Cargo manifests and Cargo.lock list every dependency with exact versions and checksums; each release also attaches a CycloneDX SBOM. | Cargo.lock, Cargo.toml |
dependency_monitoring | Met | Dependabot checks Cargo (including the fuzz workspaces) and GitHub Actions weekly on main; v12 gets no updates after its end of life. cargo audit and cargo deny run on every push and pull request and daily, and a release stops on any advisory. | dependabot.yml, supply-chain.yml |
updateable_reused_components | Met | Dependencies come from crates.io through Cargo and update with cargo update or Dependabot. The only vendored files are versioned htmx and Pico CSS browser assets (the version is in the file name), which are updated by hand. | Cargo.toml |
interfaces_current | Met | CI builds with -D warnings, so calling a deprecated dependency API fails the build. Every remaining allow(deprecated) is in a test of one of Rullst’s own deprecated compatibility APIs. | ci.yml |
automated_integration_testing | Met | Every push and pull request to main, v12 and v13 runs the automated test suite in GitHub Actions and reports the result on the commit; a nightly run covers Linux, macOS and Windows. | ci.yml |
regression_tests_added50 | Met | CONTRIBUTING.md requires a regression test for bug fixes. Of 1,139 fix commits on main and v12 from 9 April to 9 October 2026, 972 (85%) changed or added tests. | CONTRIBUTING.md |
test_statement_coverage80 | Met | Line coverage measured with cargo-llvm-cov was 92.56% on main (Codecov, 9 October 2026), and CI fails below 90%. | Codecov, coverage.yml |
test_policy_mandated | Met | CONTRIBUTING.md: “Major new functionality must include automated tests of its public behavior, failure cases and relevant trust boundaries.” | CONTRIBUTING.md |
tests_documented_added | Met | The test requirement is in CONTRIBUTING.md and in the pull-request template checklist. | PULL_REQUEST_TEMPLATE.md |
warnings_strict | Met | All CI builds use -D warnings; Clippy runs on all targets and features with -D warnings, Core denies clippy::unwrap_used and clippy::expect_used, and zero-panic gates deny panicking calls in library code. | ci.yml |
Security
| Criterion | Answer | Justification to paste | Evidence |
|---|---|---|---|
implement_secure_design | Met | The assurance case shows how each Saltzer and Schroeder principle is applied, with code: least privilege in workflows and API tokens, fail-safe defaults, complete mediation by the router-wide security baseline, separation of privilege in the release pipeline. | Assurance case |
crypto_weaknesses | Met | Defaults are AES-256-GCM, HMAC-SHA256, Ed25519, ECDSA P-256 and Argon2id, with TLS through rustls only. SHA-1 appears only as HMAC-SHA1 for TOTP, as RFC 6238 and authenticator apps require. | Assurance case |
crypto_algorithm_agility | Unmet | Rullst deliberately uses one modern algorithm per purpose (AES-256-GCM, Argon2id, HMAC-SHA256, Ed25519; JWTs use HS256 only). Stored ciphertext carries key IDs, so keys rotate without code changes, but changing an algorithm needs a release; TLS negotiates among several rustls cipher suites. | Vault |
crypto_credential_agility | Met | Keys and credentials are read from the environment or configuration (APP_KEY, RULLST_ENCRYPTION_KEY or a keyring), never compiled in. Keyrings with key IDs allow rotation, and documented placeholder keys are refused. | Assurance case |
crypto_used_network | Met | Provider integrations use HTTPS through rustls, AI provider URLs must be HTTPS (or literal loopback for local models), and remote PostgreSQL requires sslmode=verify-full; the security headers add HSTS. | Assurance case |
crypto_tls12 | Met | TLS is provided by rustls, which supports TLS 1.2 and 1.3 only. | Cargo.lock |
crypto_certificate_verification | Met | rustls verifies certificates by default, and no first-party code disables verification (no danger_accept_invalid_certs or custom no-op verifier). | Assurance case |
crypto_verification_private | Met | Because certificate verification is never disabled, the TLS handshake is verified before any API key or bearer token is sent in an HTTP header. | Assurance case |
signed_releases | Met | Since v12.0.0, every crate archive and evidence file (and, from v12.1.0, every CLI binary) is signed as a GitHub artifact attestation through Sigstore keyless signing, so no long-lived private key exists on any distribution site. From v12.1.2 the Sigstore bundle is attached; SECURITY.md documents verification with gh attestation verify. | SECURITY.md, verifying a release |
version_tags_signed | Unmet | Release tags are not signed (v12.0.0 to v13.0.0-alpha.1 are annotated tags; v13.0.0-alpha.2 and alpha.3 are lightweight); their integrity relies on protected branches, release admission of the exact commit and the attested artifacts. To start, sign tags with git tag -s using a GPG or SSH key registered with GitHub (or keyless with gitsign) and add git verify-tag to release admission. | release.yml |
input_validation | Met | Requests are parsed into typed structures with ValidatedJson and ValidatedForm; identifiers, paths, storage keys and operators are checked against allowlists, and invalid input is rejected with a typed error. | Forms and validation, assurance case |
hardening | Met | Secure headers (HSTS, nonce CSP, frame denial, COOP, COEP, CORP), unsafe limited to five reviewed files, forbid(unsafe_code) in four crates, zero-panic Clippy gates, bounded JSON size and depth, and fail-closed defaults. | Security architecture |
assurance_case (URL required) | Met | The assurance case summarizes the threat models, lists the trust boundaries, argues the secure design principles and maps common weaknesses to mechanisms and named tests, with an explicit limits section. | Assurance case |
Analysis
| Criterion | Answer | Justification to paste | Evidence |
|---|---|---|---|
static_analysis_common_vulnerabilities | Met | CodeQL analyzes Rust and JavaScript/TypeScript after every push to main, on pull requests to the maintained lines and weekly. Clippy, cargo audit, cargo deny and the IDOR route audit also run in CI. | codeql.yml |
dynamic_analysis_unsafe | Met | Rust is memory-safe outside unsafe, which is limited to five reviewed OS-integration files. AddressSanitizer and ThreadSanitizer run nightly on twelve crates; Miri, Kani and 42 fuzz targets run as on-demand campaigns. | sanitizers.yml, fuzzing.yml |
Summary
| Answer | Count | Criteria |
|---|---|---|
| Met | 50 | All others |
| Unmet | 5 | dco, bus_factor, internationalization and crypto_algorithm_agility (SHOULD); version_tags_signed (SUGGESTED) |
Every MUST criterion is answered Met. The unmet SHOULD and SUGGESTED criteria do not block Silver because their justifications are filled in. See also the OpenSSF Scorecard evidence.
v12 stable release audit
Historical record. This audit describes the 12.0.0 release. The 12.x line reached end of life on 16 October 2026 (12.3.0 was its last release); for v13 see the delivery plan.
Status: Rullst 12.0.0 was published on September 15, 2026 from immutable
tag v12.0.0 at
commit
eb11f892ae28f076e7a83c38a635316c6ed89028.
The protected release workflow
completed its admission, artifact verification, attestation, publication
preflight, sixteen-package Trusted Publishing, registry verification, and
GitHub release jobs successfully.
Stable-source verification receipts
These successful runs all identify eb11f892 as their source:
| Evidence | Workflow run |
|---|---|
| Multi-platform CI and 94/A repository scorecard | 34895536764 |
| LLVM line coverage: 90.3220% repository, 90.6186% framework libraries | 34895523751 |
| Documentation | 34895523669 |
| Full 40-target fuzz campaign | 34895536740 |
| Bounded Kani harnesses | 34895536646 |
| Scoped Miri checks | 34895536650 |
| Property tests | 34895536923 |
| Sanitizers | 34951576777 |
The release admission policy lists the complete required workflow inventory. The informational mutation campaign below belongs to its separately recorded earlier SHA; it is not a fresh mutation result for the stable commit.
Each hosted result remains evidence for its recorded commit, inventory, and environment rather than every future tree. This repository-owned audit is not an independent certification.
Baseline and method
The audit began on candidate a6b3bc8a, which included the now-integrated
second-computer CLI findings, fail-closed SQLite schema validation for automatic
migration generation, and the corrected mutation inventory. All 22 applicable
push workflows passed on that SHA. The immutable RC tag is
v12.0.0-rc.1 at
2b19567e8d184ad3ceeebae812de71b06d32e484.
Stable preparation then synchronized package versions, public documentation, site copy and release evidence and added bounded correctness/hardening changes to CSRF composition, session-cookie handling, generated registration/billing/container contracts and Studio’s browser assets. Those runtime changes earned fresh evidence before the final tag; the stable publication did not inherit an RC pass merely by description. The obsolete temporary CLI handoff file was retired after integration and remains available in Git history.
Review covers every published crate. IoT receives only a light triage under the owner’s explicit v12 exception. Each deep review traces public inputs through validation, state and side effects, compares documented behavior, and adds negative regressions for reproduced defects. Existing tests alone do not close a finding. Regressions are run before and after corrections when feasible.
This is repository-owned code review and testing, not an independent security assessment, provider certification, or a claim that every line or deployment configuration has been exhaustively analyzed. Tests are serialized on the memory-limited local machine. The final local workspace gate completed after the correction batch.
Evidence-bound repository grade
The authoritative scoring method remains the
quality scorecard; this audit does not create a second,
more flattering grading system. Under that policy, the reviewed implementation
supports a maximum local aggregate of 94/A when all conditioning gates are
green. Each of the 15 active non-IoT crates has an A ceiling, while
rullst-iot retains the owner-approved 83/B exception because physical
device and boot-chain evidence is outside the v12 campaign.
That is an evidence-bounded engineering grade, not a security certification,
feature-completeness percentage, independent audit result, or release
authorization. A candidate earns the recorded grade only when its exact SHA
passes the applicable scorecard constraints. The RC met those conditions only
after its automatic and manual gates, packaging checks, documentation review,
and explicit GO decision completed. Stable v12 subsequently passed its own
conditioning gates at eb11f892, as recorded above; neither result authorizes
unverified future changes.
Post-publication documentation closeout
The maintenance cleanup separates the published v12 record from the v13
backlog, replaces oversized historical planning pages with English archive
pointers, and preserves the original claims and evidence under the immutable
v12.0.0 tag. The temporary CLIFIX.md handoff is retired; the frozen
190-claim historical classification remains machine-checked independently of
the shortened release page.
README and landing-page examples are sourced from the independently maintained examples repository, not advertised as fresh stable-version or platform certification. Local documentation builds, source-link/anchor checks, spelling checks, and real-browser layout/accessibility checks validate the edited presentation. They do not establish that every tutorial or external provider was newly tested end to end in production.
Coverage ledger
| Crate / surface | Review scope | Current status |
|---|---|---|
rullst-orm | Projection identifiers, empty-set predicates, tenant/global scopes, transactions, policy mutations, nested queries and search | Reproduced isolation/transaction defects corrected; focused default/strict-SQLite/Redis regressions and the final all-feature workspace gate are green; live external-backend matrices remain release evidence |
rullst-orm-macros | Generated SQL bindings, parser diagnostics, portable identifiers and scope generation | Corrected generated contracts; 43 unit tests, one smoke test and 24 compile-fail cases green |
rullst-core | HTTP security composition, CSRF, lifecycle and development state ownership | CSRF/security composition 21 tests green; four reload tests and actual Node client behavior tests green |
cargo-rullst | Remote CLI handoff, public profile accuracy, supervised restart, generated contracts | Supervisor, dashboard, command-behavior, public-profile and materialized blueprint gates are green; snapshot launch retries bounded transient Linux executable-busy races; automatic SQLite migration generation now rejects unsafe database-owned identifiers before writing Rust source |
rullst-auth | JWT expiry/revocation, encrypted sessions, role guards, passkey/SQLite cancellation | Corrections green: 60 library tests and five durable JWT integrations |
rullst-security | WebSocket origin enforcement, middleware readiness, bounded redaction, crypto/input policies | 159 library tests and two Tower tests green; final rate-limit run passed 11 tests including two added afterward (161 library cases now) |
rullst-connect | OIDC claims/nonce, refresh semantics, callback state and token lifetimes | Corrections green: 204 library tests with Axum-session and SQLite features |
rullst-capital | Provider side effects, pricing, charge binding, authenticated payload schema and signature protocols | 99 library plus 22 integration tests green with Actix; one later Actix duplicate-header regression also green |
rullst-nexus | Admin transport/origin/authorization and tenant/audit boundaries | Corrections green: 50 library plus 11 integration tests, including real SQLite tenant/audit cases; the default coverage pass now includes those cases and its shared-pool race was removed, with 10 consecutive parallel integration runs green |
rullst-studio | Local operator boundary, handoff layout/telemetry changes, dynamic HTML | Static boundary review found no additional reproduced defect; 48 library tests, integrations and the final workspace gate are green |
rullst-macros | Escaping/raw HTML, generated handler/runtime contracts | Static trust-context review completed; no new reproduced defect; final all-feature workspace tests and doctests are green |
rullst-ai | Provider/mock separation, redirect handling, response limits and tool-policy boundaries | 102 library tests green, including real local HTTP regressions for all five native transports |
rullst-mail | Provider side effects, transport limits, attachments/headers, suppression and delivery evidence | 98 library tests green with SQLite, including actual HTTP and cancelled-write regressions |
rullst-messaging | Publication/lease/retry/idempotency, local durability and outbox composition | Cancellation defect corrected; focused SQLite evidence and final all-feature workspace coverage, including the optional ORM outbox relay, are green |
rullst | Facade feature wiring, composed subsystem and tutorial contracts | Static facade/feature review; existing verified-TLS composition retained; final composed all-feature workspace tests and doctests are green |
rullst-iot | Manifest/public capability honesty only; no hardware or deep audit | Light review complete; README/manifest agree on helper/simulator/transport boundaries; approved scope exception |
Counts above describe separate focused runs and overlap; do not sum them into an
invented coverage metric. In addition, the complete workspace test suite passed
with all features, and workspace Clippy passed with all targets, all features
and -D warnings.
Reproduced defects and correction boundaries
| Area | Observed failure | Correction and remaining boundary |
|---|---|---|
| ORM projections | Safe-looking select/pluck accepted SQL expressions | Validate safe projection identifiers; deliberate raw SQL remains a caller-owned escape hatch |
| ORM membership and scopes | Empty IN matched all rows; OR escaped tenant/global/soft-delete constraints | Empty sets are false predicates; group mandatory scopes separately and preserve nested query errors |
| ORM search | Local and external Scout paths bypassed model scopes; empty external results could select ID zero | Start from the scoped query, reject missing context before provider calls and bind provider IDs through empty-aware membership |
| ORM transactions | pluck, streaming/eager paths or mutation callbacks could bypass/wait on an already borrowed transaction | Use the managed executor; release query locks before callbacks/eager work where supported; unsupported mutation-callback reentry returns a typed validation error instead of hanging |
| ORM mutations/macros | Bulk delete bypassed model policy; keyset iteration could escape its cursor; unsupported identifiers reached malformed generated code | Reject unauthorized bulk mutation, group keyset predicates, propagate errors and emit compile diagnostics for unsupported identifiers/scopes |
| Durable local stores | Cancellation during manual BEGIN left uncommitted state in a pooled connection | SQLx RAII transactions in Auth JWT/passkey, Mail suppression and Messaging; cancellation racing dispatched commit still requires reconciliation |
| Authentication | Expired revoked JWT could become accepted during skew allowance | Enforce hard expiry independently of permitted clock skew; no distributed revocation claim |
| OAuth/OIDC | Missing claims, nonce downgrade, refresh-token confusion, empty callback state and invalid lifetimes | Strict provider-specific validation and checked positive bounded lifetimes; real accounts and distributed one-shot callback storage still need external evidence |
| Security middleware | HTTP/2 CONNECT bypassed WebSocket origin policy; cloned Tower services lost acquired readiness | Apply the origin guard to the extended method and call the ready service instance |
| Abuse controls/logs | Reset zero-limit admission, counter overflow, concurrent capacity escape and redaction suffix leakage | Checked bounded admission, atomic capacity/reclamation and bounded fail-closed redaction; controls remain process-local |
| Core CSRF | Empty proofs accepted; valid split Cookie fields rejected; duplicate proofs ambiguous | Nonempty bounded unique tokens, multi-field cookie parsing and exact supported form media type; unsigned double-submit is not a session-signed CSRF scheme |
| Production CSRF composition | An application router with an explicit CSRF layer was wrapped by the Server production baseline, so a first GET emitted two different cookies and the rendered form echoed only one; browsers retained the other and valid submissions failed with 403 | A private request marker makes nested framework/application CSRF composition idempotent while preserving validation at the outer boundary; the exact two-layer regression requires one matching cookie and a successful matching POST. Applications still own HTTPS, session authentication and exact exemptions |
| Examples Omni/HTML/COEP report | A generated Tauri package used colliding Windows MSVC PDB target names; html! rejected source comments; the deployed LMS needed reviewed cross-origin media behavior | Give the Omni library a distinct rullst_omni_lib target and compile-test the emitted entrypoint; strip bounded source comments in the macro parser; retain require-corp by default while exposing a closed, validated Core COEP application setting. Cross-origin media still requires matching CSP, server headers and deployed-browser evidence |
| Nexus operator access | An absolute HTTPS URI impersonated verified TLS; local Host/Origin boundary incomplete | Require the private verified-transport capability and validate local browser Host/Origin; deployment proxies must supply the correct trusted adapter |
| Capital live operations | Fabricated portals/no-op mutations, four hardcoded prices and undocumented mock aliases | Explicit unsupported errors for unimplemented live behavior; deterministic mocks only through documented mock credentials; consult the crate’s provider-method matrix |
| Capital receipts/webhooks | Incomplete authenticated payloads inferred active/paid; charge identity insufficiently bound; Polar/MP body-only signatures did not represent their protocols | Validate required event/status/charge bindings; bounded Polar header-based Standard Webhooks verification; incompatible legacy live signature paths fail closed, including MP until its full provider verification is implemented |
| AI/Mail transports | Redirects forwarded private request content; AI JSON unbounded; suppression cancellation leaked state | Pooled redirect-disabled clients, connection/request budgets, bounded native AI responses and SQLx rollback ownership; native custom endpoints remain trusted operator configuration |
| Public DLL reload | Windows LMS loaded an independent ORM/runtime state and unsafe cross-runtime workarounds were proposed | Remove public DLL generation and use directly linked supervised restart; retained legacy loader is experimental and not a stable Rust ABI |
| Automatic migration generation | SQLite table and column names reached a metadata query and generated Rust comments/strings without validation | Reuse the strict bounded database-identifier policy, bind the SQLite metadata lookup and reject the complete schema before generating any file; regressions cover newline-bearing table and column identifiers |
| Release coverage | The earlier final-main LLVM artifact reported 78,962/87,941 lines (89.7897%) while the upload job itself stayed green; default Nexus SQLite/audit paths were omitted | Nexus is now included in the merged default-profile pass and exact 90% whole-repository and framework-library floors run before upload. PR #183’s hosted artifact reported 79,349/87,941 lines (90.2298%) overall and 59,767/66,004 (90.5506%) across 435 governed framework-library files; the frozen release SHA must repeat this gate |
| Fuzz campaign | Earlier hosted runs found complete-tree rendering in unsupported-union and missing-ID model diagnostics. On 36411ea1, 39 of 40 targets completed their full 5.5-hour campaigns; fuzz_parser alone found a third valid derive tree where unconditional Field::span() validation exceeded the ten-second per-input limit | Anchor model, relation, field and unsupported-type diagnostics to bounded identifiers instead of rendering complete syntax trees; retain all three discovered shapes in the parser corpus. The latest exact ASan reproducer improved from a repeatable 12.1-second timeout to about 30 milliseconds locally; a fresh five-minute local campaign completed 1,740,804 executions without a finding, and all 43 macro unit tests plus 24 compile-fail cases are green. Hosted diagnostic run 34495340300 then completed 1,541,970 executions in 301 seconds on corrected code commit 40c1b083, with no finding. The subsequent complete 40-target campaign passed on 7697fb8a in run 34642351302; its evidence is carried only across the bounded final delta described below |
The parser correction also passed the exact local workspace gates:
cargo test --workspace --all-features and
cargo clippy --workspace --all-features -- -D warnings. This is local
evidence only and does not replace the hosted final-candidate matrix.
The adversarial regressions use local databases, mock keys, signed synthetic tokens and loopback HTTP servers—not real credentials or real payment requests. Provider capability corrections are observable behavior changes: callers must handle explicit errors where previous code returned misleading success.
AI-assisted security review evidence
A read-only Codex Security deep scan inspected representative repository
surfaces and preserved a partial, not exhaustive, report after reaching its
configured budget. It reported no Critical or High finding and independently
validated one Low-severity, Medium-confidence generated-source injection path
in make:migration:auto. The finding required control of a selected SQLite
schema, explicit generator invocation and a later insufficiently reviewed build,
but its possible impact justified correction before the RC.
The follow-up working-tree scan reviewed both changed production files but reached its smaller budget during threat-model construction, before final validation. It produced no report and is deliberately not counted as a pass. The release evidence for the correction is therefore the source trace, the two crafted-SQLite regressions, the complete all-feature workspace tests, strict workspace Clippy and the subsequent hosted candidate gates. This AI-assisted review is neither an independent audit nor a claim of complete repository coverage.
Website, README and first-run documentation
The organization root website and the framework Pages site were different
deployments. The old organization site still described main as v5 and dev
as v12, and its privacy page asserted unverified worldwide legal compliance.
The source correction prepared matching copy for both entry points. This
repository’s stable Pages deployment
passed; the organization-root deployment remains a separately operated site.
The RC copy presented v12.0.0-rc.1 as an explicit evaluation candidate and
marked v5 end-of-life. The stable documentation presents
12.0.0 as the supported v12 line while retaining application-specific
security and deployment limits.
The landing uses local CSS/JavaScript/images, finite reduced-motion-aware animation, thirteen owner-supplied social links and a concrete privacy notice. No analytics, social embeds or browser storage were added. Benchmark templates replace remote fonts with system fonts and pin Chart.js with integrity metadata; the remaining jsDelivr request is disclosed. The README preserves the top and bottom dedication, workflow dashboard, genuine coverage/Scorecard badges and evidence boundaries. It corrects the obsolete frontend-profile advertisement.
The beginner learning page links existing authoritative tutorials instead of creating another competing API reference. Initial guides clarify matching CLI installation, optional persistence, first-build time, actual generator paths and how to verify a visible result.
Verified locally for the stable-promotion worktree: mdbook build docs,
python3 .github/validate-site.py, node --check docs/site.js, and
node .github/site-browser-smoke.mjs. The Chromium test passed desktop,
390/320-pixel layouts, keyboard/mobile menu behavior, clipboard success/denial,
privacy disclosure, reduced motion, no-JavaScript navigation and no external
landing requests or browser storage. The RC organization-site export previously
passed the same smoke suite with --organization-site; the stable copy still
requires its separate deployment receipt. This is not a WCAG or cross-browser
certification.
Development reload decision
The public v12 development loop uses supervised process restart. Both
cargo rullst dev and cargo rullst dash enable it automatically; plain
cargo run remains ordinary execution. The wizard no longer asks for a DLL
profile, and the legacy scaffold flag fails with migration guidance.
A successful build precedes stopping the existing child. Compilation errors leave that child serving. Each process runs an owned executable snapshot, so a Windows executable lock does not prevent the next Cargo build. The browser refreshes through a same-origin generation probe after a new server responds. State in memory resets; this does not promise zero-downtime deployment.
The tutorial explains the contract. The v13 decision is evidence-driven: compare measured reload time, failure recovery, process cleanup, memory and state ownership across databases and operating systems before considering a different architecture.
Pre-stable evidence retained
- Approved code candidate
a6b3bc8apassed all 22 applicable automatic push workflows. Codecov reported 91.49% repository coverage and 100% patch coverage; the separately enforced framework-library component also passed 90%. - The dependency, code-scanning and secret-scanning APIs each reported zero open alerts after the hosted candidate completed.
- Sixteen exact
12.0.0-rc.1archives passed metadata/topological preflight, content audit and Cargo package verification. An extracted consumer compiled, the packaged CLI installed and reported the correct version, and all six installed-CLI blueprints generated and compiled without monorepo paths. - The complete 40-target hosted fuzz campaign passed on parent
7697fb8ain run34642351302. It remains historical RC evidence. Core/Auth and generated-application runtime changes subsequently required a fresh campaign; stable-source run34895536740passed all forty targets. - The full mutation campaign is complete. The originating run
34688592153left six of eighty shards incomplete; exact-SHA recovery run34738841341completed eleven of twelve halves before GitHub twice shut down the runner at the same resource-intensive ORM mutant. Finalization run34761010296split only that remaining half again, bounded its internal test timeout and passed both fragments, the aggregate and the evidence boundary. The verified content-addressed artifact reports 14,391/14,391 candidates classified across 87 complete artifacts: 8,705 caught, 3,553 missed, 76 timed out and 2,057 unviable, for a conservative caught percentage of 70.57%. Its source is1ffdf0a72577d1a974b4e87e2bf207f4a3580243; inventory SHA-256 is986d5cc1de71f7c8afbae7823a8fca53304b84f8e81b8f9e2d1fd3949c15ef80. Mutation remains informational: full classification is valid sensitivity evidence, while surviving and timed-out mutants remain explicit follow-up findings rather than being mislabeled as caught. - The owner supplied explicit GO for
v12.0.0-rc.1on September 12, 2026 after reviewing the package graph and topological plan. The later stable closeout repeated the applicable exact-SHA gates and completed the protected publication pipeline. The 94/A repository score remains supporting evidence, not a security certification.
RC lineage and stable publication receipt
- Tag
v12.0.0-rc.1resolves to2b19567e8d184ad3ceeebae812de71b06d32e484. The release workflow’s verify and publish jobs succeeded on that SHA. All sixteen packages were published in topological order, indexed with the verified archive checksums and exposed through docs.rs; the owner also completed a separate-machine CLI smoke test. - The GitHub release is marked as a prerelease and contains all sixteen crate archives, checksum files, Cargo metadata/lockfile, Cargo Audit JSON, CycloneDX 1.5 SBOM, bounded security evidence, advisory policy, ownership evidence and tag context. GitHub’s SHA-pinned build-provenance attestation verified for the archives and evidence bundle.
- The overall historical run is red only because the additional reusable SLSA
generator failed during environment detection: its transitive
[email protected]reference violated this repository’s full-SHA Action policy. It never generated or uploaded provenance. It is not counted as release evidence, and no SLSA level is claimed. Stable preparation removed that redundant external job while retaining the successful SHA-pinned GitHub attestation already produced inside the verified job. - All package names are registered. Trusted Publishing is configured by the
owner for all sixteen packages, the temporary bootstrap secret was removed,
and the exposed bootstrap token was revoked. Stable publication used the
protected
crates-ioenvironment and short-lived OIDC credentials; the bootstrap path remains inactive. - Stable
12.0.0promotes the audited RC lineage plus the declared post-RC corrections: idempotent nested CSRF composition, stricter session-cookie parsing/logout, transactional generated LMS registration, POST/CSRF billing mutations, hardened generated containers, self-contained Studio assets, collision-free Omni targets, source-comment parsing and explicit validated COEP application policy. - Tag
v12.0.0resolves toeb11f892ae28f076e7a83c38a635316c6ed89028. Release run34955468167reproduced and verified the archives, created SHA-pinned build-provenance attestations, published all sixteen packages in topological order, verified registry availability and checksums, and created the non-prerelease GitHub release.
Residual limitations for the next reviewer
- HTML escaping does not make
RawHtml, custom escaping implementations, JavaScript contexts or URL policies safe automatically. RPC parameters are untrusted inputs, not identity assertions. - Some direct session/passkey helpers depend on caller input-size bounds even when HTTP middleware limits requests. Cookie isolation matters for the unsigned double-submit scheme. Local rate controls are not distributed limits.
- OIDC fixtures do not establish live-account acceptance, JWKS stampede resistance or multi-host atomic callback consumption.
- NFS-e remains preparation/offline evidence, not official fiscal authorization. Payment fixtures do not homologate providers or fully model every event schema.
- SQLite cancellation tests prove rollback ownership before commit; they cannot make cancellation during a dispatched commit into exactly-once knowledge.
- Some older route smoke tests accept unavailable-database responses and prove route presence only. Real SQLite behavioral tests are identified separately.
- IoT received only the agreed manifest/README review. No physical devices, browser WebAuthn ceremony, store signing, live provider or deployment proxy certification was performed.
Evidence handling
Confirmed security defects remain local until corrections and regression evidence are ready for a coordinated commit. Findings are classified by impact, not by whether the previous scorecard happened to be green. Provider behavior that cannot be completed within the current v12 contract must fail explicitly and be documented as unsupported; mock success is not live-provider evidence.
Repeatable focused verification receipts
These focused commands and the final local preflight succeeded during the
September 5–6 correction batch. The September 8 PR #183 tree, merged as
adb83c8b, independently repeated cargo test --workspace --all-features,
strict all-feature workspace Clippy, formatting and diff checks successfully:
CARGO_BUILD_JOBS=1 cargo test -p cargo-rullst --lib -- --test-threads=1
CARGO_BUILD_JOBS=1 cargo test -p rullst-core --lib server::dev_reload -- --test-threads=1
node rullst-core/src/server/dev_reload/client_tests.cjs
CARGO_BUILD_JOBS=1 cargo test -p rullst-nexus --lib --tests -- --test-threads=1
CARGO_BUILD_JOBS=1 cargo test -p rullst-security --lib rate_limit::tests -- --test-threads=1
CARGO_BUILD_JOBS=1 cargo test -p rullst-ai --lib
CARGO_BUILD_JOBS=1 cargo test -p rullst-mail --features sqlite --lib
CARGO_BUILD_JOBS=1 cargo test -p rullst-connect --features axum-session,sqlite --lib
CARGO_BUILD_JOBS=1 cargo test -p rullst-capital --features actix --lib --tests
CARGO_BUILD_JOBS=1 cargo test -p rullst-capital --features actix --lib middleware_rejects_duplicate_standard_webhook_headers
CARGO_BUILD_JOBS=1 cargo test -p rullst-messaging --features sqlite -- --test-threads=1 --quiet
CARGO_BUILD_JOBS=1 cargo clippy -p cargo-rullst -p rullst-core --lib -- -D warnings
CARGO_BUILD_JOBS=1 cargo clippy --workspace --all-targets --keep-going -- -D warnings
CARGO_BUILD_JOBS=1 cargo test --workspace --all-features
CARGO_BUILD_JOBS=1 cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all -- --check
git diff --check
bash .github/check-historical-roadmap-ledger.sh
bash .github/check-crate-architecture.sh
ORM default/strict-SQLite/Redis and macro compile-fail results are recorded in the coverage ledger. Live-service backend verification remains distinct from the passing all-feature compile, lint and local workspace gates.
Partial-publication and recovery runbook
This runbook covers an interrupted multi-crate publication. A crates.io upload is irreversible: a version can be yanked but cannot be deleted or replaced with different bytes. Recovery therefore resumes forward from verified artifacts; it never tries to overwrite an accepted version.
Preconditions
- Freeze merges and deployments that consume the affected prerelease.
- Preserve the release workflow run, tag, commit,
checksums.txt, attestation, SBOM, audit output and every.cratearchive. - Revoke a registry token immediately if credential compromise is suspected.
- Assign an incident owner and record times, commands, registry responses and affected package versions.
Name ownership and first-publication credentials
The machine-readable policy in .github/crates-ownership-policy.json separates
the expected crates.io owner (venelouis) from the GitHub identity trusted to
run the release (Rullst/Rullst). The bootstrap allowlist is not an ownership
claim: it is the reviewed set of names that may still return 404 before their
first publication.
.github/check-crates-ownership.sh runs once in the verification job and again
immediately before publication. Every registered package must include
venelouis among its owners. Every unregistered package must be present in the
bootstrap allowlist. A name registered by another owner, an unexpected missing
name, an API error, or a malformed policy stops the release.
The following one-time bootstrap procedure was completed before the first v12 RC and again for the four new v13 names (see below). All twenty package names are now registered, so future releases must use Trusted Publishing and must not recreate a bootstrap secret unless a new, reviewed package name is introduced:
Historical procedure: the current release.yml has removed this token
fallback and refuses every unregistered package, including on prerelease tags.
Adding a name to the allowlist does not enable publication. Do not set the old
secret and expect the steps below to work on the current workflow. A new name
needs a reviewed change that restores the fallback, merged before the release
admission, as PR #452 did.
- Protect the GitHub
crates-ioenvironment with required review and tag deployment rules. - Configure Trusted Publishing for every already-registered package using
GitHub owner
Rullst, repositoryRullst, workflowrelease.yml, and environmentcrates-io. - Create a shortest-lived crates.io token with the
publish-newendpoint scope. Restrict its crate-name scope to the reviewed bootstrap names if the crates.io UI permits that combination. It does not needpublish-update: registered packages are published with the OIDC credential. - Store it only as the
CRATES_IO_BOOTSTRAP_TOKENsecret in the protectedcrates-ioenvironment. Never put it in repository secrets, logs, command history, documentation, or a local Cargo credentials file. - Start the tag workflow only after reviewing the generated ownership evidence.
The publish loop selects the bootstrap token only for packages classified as
unregistered-reviewed-bootstrap; registered packages use the short-lived Trusted Publishing token. - After all new packages are indexed, configure the same Trusted Publisher for each of them, enable crates.io’s trusted-publishing-only protection, revoke the bootstrap token, delete the GitHub environment secret, and remove the names from the bootstrap allowlist in a reviewed change.
Trusted Publishing cannot be configured before a crate’s first release. The one-time token is therefore an explicit, bounded exception, not a permanent fallback. See the official crates.io Trusted Publishing announcement and Cargo publishing rules.
v13 package registration
rullst-privacy, rullst-supervision, rullst-media and rullst-labs were
first published in 13.0.0-alpha.1 on October 11, 2026, from release run
38151068563.
The crates.io documentation still
requires an API token for a first publication.
The first attempt (run 38121092958) refused before uploading anything, because
the publish job had no first-publication path. Registering the names outside
the workflow was not possible: rullst-media depends on rullst-core at the
same prerelease version. PR #452
restored the fallback above for that release:
- The maintainer created a short-lived
publish-newtoken and stored it only as thecrates-ioenvironment secret. - All twenty archives matched the release checksums on crates.io.
A reviewed change after that release reverted the fallback and emptied the allowlist. The maintainer then completed step 6 for the four crates:
- set the Trusted Publisher to
Rullst/Rullst,release.yml, environmentcrates-io; - enable trusted-publishing-only updates;
- revoke the token and delete the environment secret.
The next release’s ownership check verifies that venelouis owns all twenty
names.
Lesson: check for unregistered names before the admission. The release verification job accepted reviewed bootstrap names, so the refusal surfaced only in the publish job, two hours into the run.
.github/release-preflight.pynow refuses an unregistered name before the admission is dispatched.- The verification job refuses it before packaging.
A change that restores the fallback must also remove that early refusal.
Determine the exact state
For each package in .github/release-order.json:
- query
https://crates.io/api/v1/crates/<crate>/<version>; - classify it as not indexed, indexed with the expected checksum, or indexed with an unexpected checksum;
- compare the registry checksum to the retained verified archive;
- verify that every already-published internal dependency precedes its dependent in the release order.
Do not infer success only from the exit status of cargo publish. Registry
indexing is asynchronous, so poll with a bounded timeout. The release workflow
performs this check after each package.
Recovery decisions
Nothing was accepted
Correct the failure on a new commit. If the tag or package content must change,
use a new prerelease version such as 13.0.0-rc.2. Never move a published or
publicly attested tag to unrelated content.
A prefix was accepted with matching checksums
Keep the accepted packages. Re-run the release workflow from the same immutable tag and verified artifact set. Its resume logic skips only versions whose crates.io checksum equals the retained archive and continues at the first missing package.
If source, manifest or artifact content needs any change, bump all publishable packages and internal requirements atomically to a new prerelease. Do not mix rebuilt bytes into the interrupted version.
A checksum is unexpected
Stop immediately. Do not publish dependents. Preserve evidence, revoke credentials, contact crates.io support and treat the event as a potential supply-chain incident. Yank the affected version when that reduces user risk, but remember that yanking does not remove downloaded bytes.
A dependency never becomes index-visible
Stop before its dependents. Retain the successful upload response and poll the API/index within the bounded operational window. If crates.io reports an incident, wait for service recovery; do not reorder the DAG or weaken checksum verification.
Consumer mitigation
- Yank a broken or unsafe prerelease and publish a corrected new prerelease.
- State exactly which packages/versions were accepted, which were yanked and which replacement users should select.
- Never describe yanking as deletion or as proof that no consumer downloaded the artifact.
- For a stable vulnerability, follow the advisory SLA and coordinated disclosure policy in Security advisory exceptions.
Completion evidence
Recovery is complete only when:
- every version in the tagged release inventory is indexed with its retained checksum (16 packages for v12; 20 in the current v13 candidate);
- a clean consumer resolves only registry packages and compiles;
- documentation/index pages are reachable;
- the incident timeline and any yanks/replacements are recorded;
- temporary credentials are revoked and Trusted Publishing is restored;
- the next release includes a regression for the initiating failure.
This process is package recovery, not application rollback. Database migrations, traffic rollback, secrets and deployed binaries require a separate application-specific operational plan.
Website maintenance and privacy boundaries
The public entry points have different deployment sources:
| Entry point | Repository source | Purpose |
|---|---|---|
https://rullst.github.io/ | Rullst/Rullst.github.io | Organization landing page and standalone privacy notice |
https://rullst.github.io/Rullst/ | This repository’s pages.yml workflow | Matching landing page, mdBook, images and benchmark dashboards |
gh-pages branch in this repository | Benchmark workflow data | Criterion history consumed by the Pages build; not a development branch |
The landing design and copy have one editable source: docs/home_template.html,
docs/site.css and docs/site.js. Preserve the footer dedication and the
release notice and keep it current: the 12.x line reached end of life on
16 October 2026, v13 is in prerelease until 13.0.0, and v5 is end of life. The
install command must pin the current cargo-rullst version; the validator
checks it. Use actual source and release evidence for claims; do not hardcode
aspirational coverage, scorecard, speed or certification values.
Validate before deployment
From the framework checkout, using Node 24 and a locally installed Chromium
(google-chrome, or set CHROME_BIN):
mdbook build docs
python3 .github/validate-site.py
python3 .github/test-llms-txt.py
node --check docs/site.js
node .github/site-browser-smoke.mjs
The Pages build also writes llms.txt (an index of every book page in
SUMMARY.md order) and llms-full.txt (their Markdown, with mdBook include
directives expanded; pages over 64 KiB other than the spec and the CLI
reference are listed under ## Optional) to the site root with .github/generate-llms-txt.py.
They are generated on every deployment and never edited by hand; preview them
with python3 .github/generate-llms-txt.py --out <directory>.
validate-site.py generates both files and fails when either is missing a
SUMMARY.md page or exceeds its size bound (128 KiB and 8 MiB).
The browser test checks desktop and 390/320-pixel layouts, keyboard and mobile
navigation, clipboard success/denial, privacy disclosure, reduced motion,
no-JavaScript navigation, resource failures, CSP errors, external requests and
browser storage. It is not a complete accessibility audit or cross-browser
certification. Optional --screenshots /absolute/output/directory records
viewport previews without adding binary artifacts to the repository.
Keep cached assets in sync with the page
The landing references CSS and JavaScript with a content version, such as
./assets/site.css?v=<first-16-hex-digits-of-SHA-256>. After editing either
asset, compute its SHA-256 and update its reference in home_template.html.
The validator and organization exporter reject stale versions. This is cache
invalidation, not an artifact-signing or browser integrity guarantee.
The browser regression check first stores an old unversioned stylesheet and script in a real four-hour browser cache, confirms they are reused, then loads the new landing and checks the three illustrated module cards’ actual styles. The organization privacy page shares the versioned stylesheet. Keep query parameters in any CDN cache key; an intermediary configured to ignore them requires a cache-policy change or content-hashed asset filenames instead. Previously cached HTML can still show the previous design until refreshed.
The source landing has no analytics, social embeds, remote fonts, cookies or local/session storage. CSS and JavaScript are local; ambient, hero, workflow and scroll-reveal motion is finite, progressively enhanced and disabled by the reduced-motion preference. Content stays visible when JavaScript is unavailable. Clipboard access follows an explicit button click and copies only the displayed command.
Hosting still processes requests. The privacy notice links GitHub’s statement and does not promise control of its logs or retention. Benchmark dashboards load integrity-pinned Chart.js from jsDelivr; documentation can store display preferences and contain external content. Do not extend the landing’s narrower description to those surfaces or to user-built applications. New tracking, embeds, forms or storage require a fresh privacy review before deployment.
Keep the organization website synchronized
Preserve any work in the separate website checkout before running the exporter:
node .github/export-organization-site.mjs /path/to/clean/Rullst.github.io
node .github/site-browser-smoke.mjs --organization-site /path/to/Rullst.github.io
The exporter refuses a dirty tree or wrong repository. It replaces only the
five known website files, derives privacy.html from the landing notice and
does not commit, push or deploy. Review the diff and deploy matching framework
documentation first, then the organization website. Keep independent commit
and deployment receipts for each repository. A framework push alone does not
update the organization’s root landing page.
The footer’s thirteen destination links are the owner’s supplied community list. Instagram, TikTok and YouTube handles are normalized to full profile URLs. These are normal links, not embedded feeds or a claim that account availability has been independently verified. Recheck ownership and links when the owner changes that list.
Rullst Academy product programme
Status: Rullst Academy runs at academy.rullst.win as a separate application; it is not a shipped framework capability. This page records its boundary with the framework and the evidence needed before its operation counts as framework release evidence. Academy must live in a separate repository and depend only on published Rullst packages; its product content and deployment are not part of the
rullstcrate.
Rullst Academy is a web-first platform for learning Rust and Rullst through short explanations, authoritative interactive exercises, review sessions and small practical projects. The target is an enjoyable learning product, not a claim that a framework can automatically produce good pedagogy or a universally better alternative to an existing course.
Product boundary
The browser-accessible web application is canonical. Server-side code owns
identity, school scope, authorization, course versions, grading, progress,
achievements and audit. Installing the application as a Progressive Web App
(cargo rullst make:pwa) does not create a second security or business model.
The Academy repository must:
- pin one exact Rullst RC or stable release and contain no monorepo path dependencies;
- use public CLI scaffolds and escape hatches instead of private framework modules;
- keep curriculum, content, product UI, deployment and learner support in the application;
- contribute only proven, reusable abstractions back to optional framework crates;
- provide application-level upgrade, backup/restore and recovery feedback for future releases; Academy is not a prerequisite for any Rullst release.
Focused first release
The first useful Academy release should complete one narrow journey well:
- A learner creates an account, joins the appropriate school and enrolls in a published course.
- The learner reads or watches an accessible lesson and resumes recorded progress.
- A server-authoritative quiz, matching or typed-recall activity records an idempotent result without accepting client-authored points.
- The review queue schedules a later activity and an achievement or certificate is derived from persisted rules.
- A practical project is submitted to a constrained runner and receives deterministic test feedback.
- An instructor drafts, reviews and publishes content; an administrator can audit the resulting events without crossing school boundaries.
Polish should concentrate on this journey: responsive SSR, keyboard operation, visible focus, reduced motion, clear errors, fast navigation and useful empty states. Payment, social features, advanced gamification, native-store releases, offline synchronization and a large course catalogue are not prerequisites for the first release.
Initial curriculum and projects
Two short tracks are enough to validate the product:
| Track | Initial lessons | Practical outcome |
|---|---|---|
| Rust foundations | ownership and borrowing, structs/enums, pattern matching, errors, iterators, async and tests | a tested command-line application that persists bounded data |
| Rullst web development | project creation, routes/SSR, forms, ORM/migrations, authentication/authorization, queues and deployment preparation | a secure small web application with owner-only CRUD and background work |
Every exercise must have a versioned ruleset, bounded input/output and retained test identity. Curriculum authors, not an LLM, define the expected concepts, tests, hints and completion conditions.
Safe practical-code runner
Learner code is hostile input. It must never execute inside the Academy web process or through a mounted host Docker socket. The application-owned runner must use a disposable rootless container or stronger microVM boundary with:
- no network by default and no cloud/application credentials;
- read-only base images and a fresh writable workspace per attempt;
- explicit CPU, memory, process, file, disk, output and wall-clock limits;
- a reviewed Rust toolchain/dependency policy and immutable image digest;
- bounded compile/test logs with control-character handling and secret redaction;
- an idempotent submission identity, queued execution, cancellation and terminal retry/dead-letter policy;
- cleanup after success, failure, timeout and worker restart;
- adversarial escape, fork-bomb, filesystem, network and output-amplification tests before public use.
The runner returns structured test evidence. It does not grant points directly;
the Academy service binds that evidence to the authenticated learner, project
version and server-owned scoring policy. Rullst does not ship this runner; with
rullst-labs, it implements the Labs controller contract.
Local AI mascot
Privacy and proportional age assurance are v13 P0 dependencies for the applicable learner journeys; follow the shared privacy programme. Academy must decide its audience and processing purposes, validate any guardian relationship, and implement actual export/deletion adapters. The v12 Academy scaffold’s age bands and consent records, retired in v13, are not independent age or guardianship verification. Reading lessons must not acquire a camera requirement without a justified policy.
The mascot can be a friendly tutor backed by the existing Ollama path in
rullst-ai, with a deterministic offline fallback for tests. It should use a
bounded RAG corpus containing version-pinned official Rust material, Rullst
documentation and Academy-authored hints.
The mascot may explain an error, ask a guiding question, retrieve a relevant lesson or suggest the next exercise. It must not:
- authoritatively grade code, invent completion or change persisted points;
- execute arbitrary tools or learner code outside the sandbox boundary;
- retrieve another learner’s conversation, submission or school data;
- receive secrets, raw session values or unnecessary personal data;
- present an uncited generated statement as official Rust or Rullst behavior.
Responses should carry source references and a visible “local AI tutor” label. Provider/model unavailability must leave the curriculum and deterministic grader usable. Prompt-injection regressions, context limits, tenant binding, PII masking and secret-minimized audit remain mandatory.
What exists and what remains application work
| Boundary | Current reusable foundation | Academy must still prove |
|---|---|---|
| Learning domain | The LMS example’s (examples/lms, the former v13 LMS starter) catalog, modules, lessons, accessible player, enrollment and owner-bound progress; the v12 Academy scaffold’s activities, quizzes, review, completion, certificates, leaderboard and automation were retired | those learning features, coherent product UX, content quality, complete authorship and browser E2E |
| Identity and schools | Session/RBAC helpers; the LMS example has no schools (the v12 school-scoped contracts were retired) | schools and memberships, account recovery, invitations, device/session policy and every cross-school negative |
| AI tutor | guarded providers, Ollama fallback, bounded tenant-aware RAG and audit contracts | curated corpus, pedagogy, model evaluation, capacity and user-facing failure behavior |
| Practical projects | queues, outbox and bounded messaging foundations; the rullst-labs job and grading contracts (published since 13.0.0-alpha.1) | isolated runner, immutable images, resource policy and escape testing |
| Media | bounded accessible lesson metadata, captions and transcripts; the opt-in rullst-media Bunny Stream candidate (published since 13.0.0-alpha.1) | upload, storage, scanning, transcoding, caption quality and retention |
| Operations | health/readiness, telemetry, deploy scaffolds and upgrade assistant | production topology, TLS/proxy identity, backup/restore, rollback, alerts and incident response |
Repository and release boundary
Academy will be developed in a separate repository and conversation. This document records only the intended boundary and the reusable framework foundation that already exists; it does not authorize further Academy work in the Rullst repository and it is not a framework release gate.
- v12: closed through its own gates without Academy; the line reached end of life on 16 October 2026.
- 13.0: 13.0.0-alpha.4 is the feature freeze, 13.0.0-rc.1 takes fixes and documentation only, and Academy does not need to exist or run against either.
- After 13.0: reusable improvements discovered by the external Academy may be proposed with their own bounded contracts; research-heavy or breaking work remains governed by the maintenance scope.
The open canonical milestones in the current roadmap belong to the long-term v13 horizon. They do not block 13.0.0, and Academy itself is not part of that milestone denominator.
Acceptance evidence
Before Academy can be treated as release evidence, record all of the following against immutable application and framework SHAs:
- the complete learner/instructor/admin journey on PostgreSQL, with SQLite kept as a local profile only when its declared limits are acceptable;
- anonymous, cross-user, cross-role and cross-school denial tests;
- browser accessibility and responsive-layout checks for the primary journey;
- sandbox abuse tests and bounded compile/test output;
- backup restoration, forward migration, rollback and framework-upgrade drills;
- dependency-only installation from crates.io with no Rullst path overrides;
- load and failure tests with unavailable AI, mail, cache and worker services;
- a documented human GO/NO-GO decision and remaining product risks.
Related reusable guides include accessible Academy media, tenant-bound RAG, and the assisted upgrade workflow.
Maybe SaaS: Incubating Products Built with Rullst
Status: strategy proposal, not an implementation claim. None of the products named in this document exists merely because it is described here. The capability ledger remains the evidence source for what the framework implements today.
Some of Rullst’s most ambitious ideas need more than another module in a web framework. They require a continuously operated service, official homologation, named hardware, vendor interoperability, incident response, or a dedicated security program. Those ideas can become separate products built with Rullst, while the framework remains a reusable and honest foundation.
This is not automatically an open-core strategy, and it does not require every advanced capability to become paid software. It is an incubation boundary: use the smallest delivery model that can provide credible evidence and operations.
The four possible delivery models
| Model | Use it when | What stays in the Rullst framework repository |
|---|---|---|
| Framework crate | The capability is a reusable local library and does not require a vendor-operated control plane. | Public traits, types, adapters, deterministic mocks, documentation, and contract tests. |
| Open-source reference application | Users need a deployable example or can reasonably self-host the whole capability. | The stable client contract and an example; the application can have its own repository and release cycle. |
| Managed SaaS or private control plane | The value depends on durable shared state, continuously updated provider rules, tenant operations, monitoring, or 24/7 availability. | A provider-neutral client/SDK, an explicit remote adapter, offline mocks, and a self-hosted escape hatch where practical. |
| Conformance or hardware program | Correctness depends on official test environments, physical devices, audited cryptography, interoperability matrices, or certification. | Interfaces and test vectors. Passing named external suites is required before a production claim. |
A capability can use more than one model. For example, an IoT platform can have an open device SDK, a managed control plane, and a physical hardware conformance lab.
Recommended boundary
Application built with Rullst
|
| typed client contract
v
Framework adapter + deterministic offline mock
|
| explicit opt-in; never silent fallback
v
Separately deployed product or customer-managed service
|
+-- durable state and tenant isolation
+-- provider, fiscal, or device interoperability
+-- monitoring, audit, support, and incident response
+-- independent conformance evidence where required
The framework must remain useful without the managed product. Remote services must not become hidden requirements for routing, ORM, authentication, or local development. Live configuration must also fail closed instead of silently falling back to a mock.
Candidate incubation programs
The names below are working descriptions, not announced product names.
1. Fiscal Cloud for NFS-e
Best initial form: a dedicated fiscal program, followed by a managed SaaS and a self-hostable/private deployment if the operating model proves viable.
Rullst v13 removed its bounded local NFS-e preparation module (DPS 1.01 builder, checksum-pinned XSD validation, PKCS#12 XMLDSig and mTLS client preparation), which was never validated with a real municipality; its source remains in git history as a starting point. A live product would be responsible for substantially more:
- official schemas, municipality/national variations, rejection codes, and protocol updates;
- PKCS#12 or delegated certificate custody, rotation, access control, and audit;
- official request/response envelopes, retries, reconciliation, cancellation, substitution, and immutable evidence around the local crypto/schema core;
- durable idempotency and a complete issuance state machine;
- official homologation environments, operational monitoring, and specialized support.
The fiscal engine should be a separate product with its own lifecycle, because
protocol and legal maintenance must not be coupled to releases of the framework
suite; rullst-capital no longer ships fiscal types. It must not advertise legal or tax
compliance without qualified review and current official evidence.
Why it is attractive: it solves a difficult Brazilian SaaS problem and would exercise queues, cryptography, observability, billing, multi-tenancy, and failure recovery in a real Rullst application.
Why it is risky: certification, certificate custody, protocol drift, and financially consequential failures make this much more than an HTTP adapter.
2. IoT Device and OTA Control Plane
Best initial form: open device SDK plus a hardware conformance program; managed SaaS only after selecting and testing named device families.
Rullst currently provides no_std telemetry/frame foundations and an
Ed25519-signed OTA manifest verification gate. A credible platform would add:
- device registry, provisioning, fleet inventory, and tenant isolation;
- MQTT/CoAP/LoRaWAN interoperability against named brokers and devices;
- durable anti-rollback counters, staged rollouts, download resumption, boot slots, health confirmation, and rollback orchestration;
- signed firmware provenance, software bills of materials, revocation, and incident response;
- physical test rigs covering power loss, partial writes, clock faults, poor networks, and recovery paths.
The device-side verifier should remain small, auditable, and independent of the SaaS. A customer must not brick a fleet merely because the control plane is temporarily unavailable.
3. Key Management, HSM, and Post-Quantum Interoperability
Best initial form: adapter crates and a conformance program, not a new cryptographic SaaS first.
HSM and post-quantum work is worthwhile only for named protocols, devices, and threat models. The safe path is to integrate audited libraries and standards, then validate them against PKCS#11 services, cloud KMS/HSM providers, secure elements, and published test vectors. Rullst must not invent cryptographic primitives or use a generic “quantum-safe” badge.
A managed orchestration service could eventually handle key policy, rotation, attestation inventory, and audit workflows. Actual private-key operations should remain inside the selected HSM/KMS boundary whenever possible. On-premise and bring-your-own-key modes are likely requirements, not optional extras.
4. Enterprise Identity Gateway
Best initial form: a separate identity service or private control plane, with a dedicated crate boundary if reusable protocols are added.
rullst-connect is currently focused on OAuth2/OIDC and social identity.
Enterprise SAML, SCIM provisioning, organization/domain discovery, directory
synchronization, delegated administration, policy evaluation, and tenant audit
are a coherent product of their own. They should not be represented as a few
extra provider flags in the OAuth crate.
The framework can expose authentication/session integration and typed identity events. The gateway can operate federation metadata, provisioning jobs, enterprise connectors, and tenant-specific policy. A private deployment option is important for organizations that cannot send identity metadata to a shared SaaS.
5. Security Operations Control Plane
Best initial form: a self-hostable reference service before any managed SaaS claim.
Rullst Security, Radar, Studio, and Nexus already provide useful local security and telemetry foundations. A separate control plane could aggregate signed events from multiple applications, manage distributed rate-limit policy, deliver alerts to SIEM destinations, retain audit evidence, and coordinate incident response.
This product would need explicit data-retention controls, regional placement, tenant isolation, end-to-end authentication, redaction, bounded ingestion, and an unavailable state that never weakens an application’s local defenses. Claims such as autonomous blocking, zero leakage, or complete OWASP coverage would still require narrow definitions and independent evidence.
6. Messaging and Remote Storage
Best initial form: provider-neutral crates and conformance suites before a managed service.
Kafka, RabbitMQ, Redis Streams, S3, and R2 are integrations with mature
services. Rullst now has a coherent bounded rullst-messaging contract for
idempotency, groups, leases, retry and dead letters, with a deterministic
process-local broker, canonical envelope/trace contracts and opt-in durable
local SQLite state with explicit encrypted content. Since 13.0.0-alpha.1,
opt-in candidates add a standalone Redis Streams profile (messaging-redis) and private
S3-compatible object storage with resumable multipart uploads (storage-s3,
storage-multipart), tested against disposable local services. Other remote
adapters still need provider-specific conformance, backpressure and
deterministic mock evidence, and live provider interoperability remains
unvalidated.
Building another broker or object store as a SaaS would add little value until
Rullst applications reveal a concrete unmet need.
What should remain inside the framework
Even when a separate product exists, Rullst should own:
- stable, provider-neutral traits and serializable contract types;
- feature-gated client adapters with bounded timeouts and typed errors;
- deterministic offline mocks selected only by explicit mock configuration;
- local development and self-hosted paths where they are practical;
- contract tests that every official or third-party provider must pass;
- telemetry hooks that expose availability without fabricating success;
- migration and escape-hatch documentation that prevents vendor lock-in.
The separate product should own production tenancy, billing, durable global state, operational dashboards, on-call response, external-provider drift, certification evidence, and service-specific data governance.
Promotion gates
An incubated idea must not be promoted from roadmap to implemented because a demo or one happy-path adapter exists. Before a public production claim, require the applicable gates:
- a named owner and a documented support/release policy;
- a threat model, abuse cases, tenant-isolation tests, and data classification;
- a versioned API contract, deterministic mock, and self-host/exit strategy;
- end-to-end tests against named providers, devices, or official environments;
- migrations, backup/restore, disaster recovery, observability, and SLOs;
- bounded retries, idempotency, reconciliation, and failure-injection tests;
- security review and, for cryptographic/fiscal work, independent specialist validation;
- a private beta with real operators before general-availability language;
- evidence linked from the capability ledger and the relevant roadmap.
Passing framework unit tests is necessary, but it cannot substitute for these external and operational gates.
Suggested order of investment
- Shared foundations: distributed idempotency/rate limiting, canonical Security contracts, complete scaffold validation, and reproducible releases.
- One reference product: build and operate a narrowly scoped Rullst service to validate deployment, multi-tenancy, telemetry, upgrades, and support.
- Fiscal discovery or enterprise identity: choose one based on access to qualified domain partners and real design customers; do not start both as production programs simultaneously.
- IoT control plane: proceed only with named hardware, a physical lab, and a partner willing to test real update failures.
- HSM/PQC: integrate audited standards for concrete use cases after the threat model exists; keep speculative branding out of production claims.
The first reference product does not need to be the most spectacular one. Its purpose is to prove that applications built with Rullst can be upgraded, observed, secured, and operated for long periods. That operational evidence is more valuable to the framework than another unchecked feature list.
Strategic conclusion
Yes, a separate SaaS built entirely with Rullst can be an excellent direction. It creates a demanding real customer of the framework and can finance deeper engineering. The separation is successful only if it protects both sides:
- the framework stays open, portable, provider-neutral, and truthful;
- the product can evolve at the cadence required by its domain;
- neither side claims external certification or production readiness without evidence;
- every managed convenience has an explicit contract and a credible exit path.
The goal is not to move unfinished features behind a hosted API. It is to give the ideas that require operations, certification, or hardware the independent engineering program they need to become real.
Historical Documentation Sources
Rullst’s original ambition and the subsequent audit remain recoverable from immutable source snapshots:
| Historical record | Source |
|---|---|
| Documentation before the first repository audit, August 24, 2026 | Commit 96222fbd31bec3d20bc50db68c41bb85ca595779 |
| Original audit and later annotations | Archived gpt.md |
| Preservation analysis and recovered ambitions | Complete preservation record |
| Historical 190-claim classification | Complete v12 development ledger |
For current APIs, use the specification. For unfinished ideas, use the roadmap and capability ledger. Historical scores and sample code are not current release instructions.
Archived v12 Hardening Tracker
The pre-release tracker has been retired after publication of Rullst 12.0.0. Its intermediate scores, Portuguese notes, and open release checklist described earlier commits. They must not be read as the current release status.
- Read the complete tracker frozen at v12.0.0.
- v12.0.0 release receipt and review findings.
- Current capability boundaries and remaining work.
- Security controls and their tests.
This short page preserves the published URL. Open capability work remains in the capability ledger and roadmap; archiving the tracker does not close it.
Archived Framework Comparison
The August 27, 2026 comparison is a dated research snapshot. Its competitor claims and pre-release recommendations are no longer maintained as current product guidance.
- Read the complete historical comparison.
- Evaluate Rullst’s current strengths and tradeoffs.
- Check the implemented scope and limitations.
- Review reproducible performance methodology.
- Follow the current v13 comparative evaluation plan.
Any new comparison needs dated primary sources or equivalent measured workloads. This archive preserves the original research without renewing its claims.