# Rust off Rails 0.7.3: the complete framework docs > Every Markdown file of the Rust off Rails framework, version 0.7.3 (2026-10-20), concatenated. Source: https://github.com/rust-off-rails/rustoffrails. Index: https://rustoffrails.org/llms.txt ================================================================ FILE: README.md (https://rustoffrails.org/docs.md) ================================================================

Rust off Rails

# Rust off Rails > The opinionated web stack for the AI era. **Rust off Rails** is a web framework for AI coding agents. It ships no library you depend on: it's a set of Markdown files that tell agents how to build a full-stack Rust web app (one blessed stack, one directory layout, one way to do each thing), plus the **app template** those files describe, as real, tested files the agent copies instead of retyping ([template/](template/README.md)). The convention lives in the prose; the code is copied from the template and its parts, then written by the agent for everything app-specific. Humans still write none of it. ## Standing on giants We take the best ideas of two great ecosystems and bring them together in one stack. - **From Ruby on Rails:** - convention over configuration; - generators (`new`, `scaffold`), Active Record-style models and migrations; - Rails 8 authentication; - the Solid trifecta: jobs, cache and pub/sub in the database, no Redis; - Turbo 8-style page-refresh broadcasts; - mailer previews, `bin/rails` tasks, and Kamal-ready Docker with `/up`. - **From Elixir and Phoenix:** - **Oban** persistent jobs and the **Oban Web** dashboard; - **Swoosh**'s local mailbox; - **phx.gen.auth** magic links and the "check your mailbox" hint; - **LiveDashboard**-style dev tools; - **Channels, Presence and PubSub**; - multi-node clustering and "let it crash" supervision; - Ecto's explicit data layer (no callbacks); - the pending-migrations page. - **And also:** htmx, Inertia.js, Tailwind, shadcn-style owned components, and end-to-end types from Rust to TypeScript. [`RAILS_MAP.md`](RAILS_MAP.md) maps every Rails concept to its Off Rails equivalent, plus a [Borrowed from Phoenix / Elixir](RAILS_MAP.md#borrowed-from-phoenix--elixir) section. ## The deal | Rails gave you… | Rust off Rails gives your agent… | |------------------------------------|------------------------------------------------------| | A gem with 300k lines of Ruby | ~50 Markdown files and an app template the agent copies and owns | | `rails generate scaffold` | `generators/scaffold.md`: a recipe the agent runs | | Convention over configuration | Convention over *deliberation*: the agent never debates which crate to use | | "Optimized for programmer happiness" | Optimized for agent correctness: compiler-checked everything | | Magic | No magic. Explicit code is cheap when you don't type it. | ## Rules of the framework 1. **All code is written by AI. No exceptions.** Humans write intent: specs in Markdown, interview answers and feedback. Agents write every line of Rust, SQL, HTML, CSS, TypeScript, config and scripts. If a human edits code anyway, the agent treats the edit as a spec change, records it, and owns that code from then on. 2. **Strong opinions, aimed at agents.** Every decision an agent would otherwise improvise (crate choice, file location, naming, error handling, testing) is already made in these docs. 3. **Everything Rails has, mapped.** Active Record, Action Controller, Action View, Active Job, Action Mailer, Action Cable, Active Storage, Solid Cache/Queue/Cable, generators, `bin/rails`, credentials, Kamal. See [`RAILS_MAP.md`](RAILS_MAP.md). ## Using it ### Install (one line) ```sh curl -fsSL https://rustoffrails.org/install.sh | sh ``` It checks for Rust (and offers rustup), installs the cargo tools the framework uses as prebuilt binaries, downloads and **SHA-256-verifies** the framework into `~/.offrails/`, installs the Claude Code plugin if `claude` is on your PATH, and adds an `offrails` command. No sudo, no telemetry, and it's safe to re-run. Add `--dry-run` to see the plan first. [INSTALL.md](INSTALL.md) explains every step, with a manual equivalent. ```sh offrails new bookshelf "people track the books they've read" # starts Claude Code (or Codex) with /offrails:new offrails doctor # prerequisites and versions offrails update # latest release ``` ### Or by hand: Claude Code plugin ``` /plugin marketplace add rust-off-rails/rustoffrails # or a local checkout: /plugin marketplace add /path/to/rustoffrails /plugin install offrails@offrails ``` Then, in an empty directory: ``` /offrails:new bookshelf: people track the books they've read and review them ``` The agent interviews you first (accounts? who owns data? htmx or React? where will it run?), using recommended defaults so you can just say "you decide". It confirms a plan, then builds. The skills also trigger on plain requests ("make me a new Rust web app…"). | Skill | Rails equivalent | What it does | |-------|------------------|--------------| | `/offrails:new [name] [pitch]` | `rails new` | Interview → plan → app skeleton → planned features, green | | `/offrails:feature [request]` | — | Spec first, then code. With no argument: build what changed in `product/` | | `/offrails:scaffold Book title:string …` | `rails g scaffold` | Full resource in the app's frontend mode | | `/offrails:generate job\|mailer\|channel\|authentication\|model\|migration\|task …` | `rails g …` | Any other generator recipe | | `/offrails:check` | `bin/rails test` + CI | Definition of Done: fmt, clippy, tests on SQLite **and** Postgres, frontend checks | | `/offrails:doctor [--fix]` | `rubocop` + review | Audits the app against every convention in these docs | | `/offrails:deploy [fly\|kamal\|railway\|render]` | `kamal setup` | Deploy config, verified image; confirms before anything outward-facing | | `/offrails:upgrade` | `rails app:update` | Moves the app's vendored `.offrails/` to the plugin's version and migrates conventions | The plugin also installs hooks. They only act inside Off Rails apps (directories with `.offrails/`): - **Edit guard:** blocks edits to `.offrails/` (read-only) and `web/src/types/` (generated by ts-rs). - **Convention check:** after each edit, flags raw SQL outside the kernel, banned crates or packages, and `/:id` routes. - **Stop gate:** the agent can't end a turn while `cargo fmt --check` or `cargo check` fails. - **Session start:** orients the agent and notices when the app's framework version is behind the plugin's. Each app still vendors the docs into `.offrails/`, pinned to a version, so the app keeps working with any agent, with or without the plugin. ### With Codex, Cursor, or any agent that reads `AGENTS.md` ``` Read /path/to/rustoffrails/AGENTS.md and make me a new app called "bookshelf". ``` Generated apps carry `AGENTS.md` and `.offrails/`, so later sessions need no setup. ### Then Describe features in plain language, or write them as specs in `product/`: ``` Scaffold Book with title:string, author:string, published_on:date, rating:integer. Users must be logged in to create books. Send the owner an email when a book gets a review. ``` ### The template and its parts A new app is a copy of [`template/app/`](template/README.md): a complete, compiling app (htmx) with the kernel, security headers, CSRF, sessions, Host allowlist, rate limiting, log redaction, SQLite and Postgres, `/dev` tools, Tailwind, CI, Docker and the kernel tests, renamed and green in minutes instead of an hour of generated Markdown. On top of it go the **parts** the product needs: the mailer and dev mailbox, jobs and their dashboard, accounts (password, magic link, TOTP), realtime with presence, and a LiveDashboard-style dev dashboard. They're the tested samples from [rust-off-rails/samples](https://github.com/rust-off-rails/samples) (screenshots of every screen there). Your agent proposes the ones your app needs, you confirm, and later needs are one command: `cargo offrails add-part `. Once copied, the app owns the code ([guides/samples.md](guides/samples.md)). The generators (`offrails-gen`: `new`, `add-part`, `list-parts`, `generate`, `destroy`, `snippets`) are a small standard-library Rust tool, so they work the same on Linux, macOS and Windows. Features start from a **snippet** (small tested code to copy: a feed, slugs, Markdown, a pager, a theme toggle; [template/snippets](template/snippets/README.md)) or a **generator** (`cargo offrails g resource posts title:string`), both optional, both the app's once written: small files per feature, one marked line per shared spot. Pages are built from a component kit (Askama macros, light and dark, branded from one `brand.toml`) in five layouts, and `cargo offrails prove` screenshots and checks every page and writes `docs/proof/PROOF.md`, tying each acceptance criterion to its passing tests and pages. ### Field tests: real apps, ported by agents We test the framework by having agents port well-loved open-source apps to it. Each port puts the docs, the template and the parts up against a product with years of real-world design behind it, and every rough edge an agent hits goes back into the framework. - **fizzyer**: a Rust port of [Fizzy](https://github.com/basecamp/fizzy), 37signals' kanban-style tracker. Repository coming soon. - **A FreeScout port**: a Rust port of [FreeScout](https://github.com/freescout-help-desk/freescout), the open-source help desk. In progress; repository coming soon. - **uptime-offrails**: a Rust port of [Uptime Kuma](https://github.com/louislam/uptime-kuma), the self-hosted uptime monitor. Repository coming soon. The ports are built by different agents on purpose: GPT-6 Luna (high), Claude Sonnet 5.5 (high and medium), Meta's Muse Wonder 1, and smaller open models such as GLM 5.3 Flash through opencode. The goal is a framework that makes smaller, cheaper agents productive too, not only the biggest ones. They're for education and testing first. We also plan to run them ourselves, mostly to save server resources. Each will be open source under its original project's license, and contributions are welcome. They're independent ports, not affiliated with or endorsed by the original projects; credit for each product's design goes to its authors. ## Repository layout ``` AGENTS.md entry point for agents: read this first interview.md the questions agents ask before building doctrine.md the philosophy, rewritten for machines RAILS_MAP.md every Rails concept and its Off Rails equivalent stack.md the blessed crates (the "Gemfile") structure.md directory layout and naming conventions cli.md the app binary and the bin/rails equivalents workflow.md how an agent works: spec → code → green → done guides/ one guide per framework component generators/ recipes that replace `rails generate` blueprints/ files copied into every new app (AGENTS.md, spec template…) template/ the app template (app/: a complete, tested blank app) and its parts (parts/: the samples), copied by offrails-gen .claude-plugin/ Claude Code plugin + marketplace manifests skills/ /offrails:* skills (thin entry points into the docs above) hooks/ convention guards (no-ops outside Off Rails apps) evals/ `claude plugin eval` cases: does the framework steer agents right? tools/ offrails-gen/ (new, add-part, list-parts: Rust, any OS), offrails (the CLI), brand/ (favicons + social card), browser-smoke/ (kernel pages), release/, ci/ install.sh the one-line installer (served at rustoffrails.org/install.sh); INSTALL.md explains it VERSION, CHANGELOG.md framework version; apps record theirs in .offrails/VERSION ``` ## Developing the framework ```sh claude --plugin-dir . # load this checkout instead of the installed plugin claude plugin validate . --strict # manifests, skills, hooks claude plugin eval . --runs 1 # run the eval cases ``` Every doc change goes under `## Unreleased` in `CHANGELOG.md` the moment it's made, with **App migration** notes written for an agent. Each release section keeps the shape described at the top of `CHANGELOG.md` (What's new, Breaking changes, Upgrading, Full list of changes): it becomes the GitHub Release notes. Apps detect drift by content fingerprint (`hooks/fingerprint.sh`), not version alone, so even unreleased edits show up as an upgrade. Releasing: 1. Rename `Unreleased` to the new version. 2. Bump `VERSION`, `.claude-plugin/plugin.json` and `tools/offrails-gen/Cargo.toml` (+ its `Cargo.lock`) together, and commit (`release.sh` refuses a mismatch: offrails-gen's version is in Cargo's fingerprint, so an app's cached `cargo offrails` binary is rebuilt for the new release). 3. `tools/release/release.sh --dry-run` to check, then `tools/release/release.sh`. It builds a reproducible `rustoffrails.tar.gz` (the whole framework, skills and hooks included), `install.sh` and `checksums.txt`, tags `v`, and creates the GitHub release with `gh`. `install.sh` downloads `releases/latest/download/…`, so a release is all it takes. 4. Publish the release's `install.sh` at `https://rustoffrails.org/install.sh` (the website serves it). 5. `claude plugin tag --push` for the plugin marketplace. Installer tests: `tools/ci/test-install.sh` (local shells + clean Linux containers; `--full` also runs the real rustup + cargo-binstall path). Template tests: `TEST_DATABASE_URL=postgres://… tools/ci/test-template.sh` (generates the bare app, every part, parts added one by one, every generator and every snippet; the Definition of Done on SQLite and Postgres and the memory budget). Parts come from the samples repo: `tools/sync-parts.sh `, never edited here. Installed plugins update only when the version changes. ## The stack in one breath Axum on hyper (the binary *is* the web server: HTTP/1.1 + HTTP/2, no app server or nginx needed) · Tokio · SeaORM (**SQLite locally, Postgres in production, same code**) · Tailwind · a frontend mode chosen at birth: - **`htmx`** (default): Askama (compile-time checked templates) + htmx. No Node. Optional React or Svelte islands. - **`inertia`**: React or **Svelte** pages with Rails-style server routing, via a generated Inertia adapter. The default when you want a JS UI. - **`spa`**: a Vite SPA (React Router or SvelteKit in SPA mode) + a JSON API. - **`api`**: no UI, for mobile backends and public APIs (in progress). `UI_LIBRARY: react | svelte` picks the JS library. Props and API types are generated from Rust by `ts-rs`, so the type chain runs from the database to the browser. Plus a database-backed job queue, cache and pub/sub (the "Solid trifecta") · lettre · object_store · tracing · one binary, one Docker image. Yes, the acronym is still RoR. That's on purpose, and it's a thank-you. ## License Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT license](LICENSE-MIT), at your option. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. Security issues: see [SECURITY.md](SECURITY.md). Contributing: see [CONTRIBUTING.md](CONTRIBUTING.md). ================================================================ FILE: INSTALL.md (https://rustoffrails.org/docs/install.md) ================================================================ # Installing Rust off Rails ```sh curl -fsSL https://rustoffrails.org/install.sh | sh ``` Read before you pipe: [`install.sh`](install.sh) is about 300 lines of POSIX shell. Run it with `--dry-run` to print the plan and every command without changing anything. Here's exactly what it does, in order. ## What the installer does 1. **Prints the plan** for your OS and architecture. Nothing has happened yet. 2. **Rust toolchain**: - If `cargo` exists, it moves on. - If not, it asks first, then runs the official rustup installer (`curl https://sh.rustup.rs | sh -s -- -y`). It **never uses sudo**. 3. **Cargo tools** used by the framework: `sea-orm-cli` (migrations), `cargo-nextest` (tests), `cargo-watch` (`bin/dev`). Only the missing ones are installed. - Preferred route: install [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) with its official script, then `cargo binstall` prebuilt binaries (seconds). - If that fails, it falls back to `cargo install --locked` (minutes) and warns you. 4. **The framework**: - Downloads `rustoffrails.tar.gz` and `checksums.txt` from the GitHub release: latest, or `--version X.Y.Z`. - **Verifies the SHA-256** and refuses to install on a mismatch. - Unpacks to `~/.offrails//` and points `~/.offrails/current` at it. - Re-running with the same release is a no-op. 5. **The `offrails` command**: copies `~/.offrails/current/tools/offrails` to `~/.local/bin/offrails`. If that directory isn't on your `PATH`, it tells you the exact line to add. 6. **Coding agents**: - If `claude` (Claude Code) is on your PATH, it adds the marketplace and installs the plugin: `claude plugin marketplace add rust-off-rails/rustoffrails`, then `claude plugin install offrails@offrails --scope user`. It skips this if the plugin is already installed. - For Codex, Cursor or any other agent, it prints the one-line prompt that points the agent at `~/.offrails/current/AGENTS.md`. 7. **Optional tools**: it only *detects* Docker (local Postgres, image builds, deploys), Node 24 and pnpm (only for `inertia`/`spa`/islands apps). It doesn't install them. It sends no telemetry. Output is coloured only on a terminal (set `NO_COLOR=1` to disable). Any failure exits non-zero with a clear message. ## Options | Flag / variable | Effect | |---|---| | `--dry-run` | Print the plan and commands; change nothing | | `-y`, `--yes` | Don't ask (CI, scripts) | | `--version X.Y.Z` | Install a specific release | | `--no-toolchain` | Skip Rust and the cargo tools | | `--no-agents` | Skip the Claude Code plugin | | `OFFRAILS_HOME` | Framework location (default `~/.offrails`) | | `OFFRAILS_BIN_DIR` | Where `offrails` goes (default `~/.local/bin`) | | `OFFRAILS_SOURCE` | A directory or URL holding `rustoffrails.tar.gz` + `checksums.txt` (offline installs, mirrors) | Pass options through the pipe like this: `curl -fsSL https://rustoffrails.org/install.sh | sh -s -- --dry-run`. ## The `offrails` command ```sh offrails new bookshelf "people track the books they've read" # Claude Code: /offrails:new …; else Codex; else prints the prompt offrails doctor # table of prerequisites and versions offrails version offrails update [--version X.Y.Z] # re-runs the installer offrails uninstall # removes ~/.offrails and the command (not Rust, not your apps) offrails --help # that command's help; never runs anything ``` `new` and `upgrade` start an agent (Claude Code, else opencode, else Codex; `OFFRAILS_AGENT` picks one). When stdin is not a terminal (CI, a script, another agent) they need `--yes` or `OFFRAILS_YES=1`; without it they print the agent command and exit 3. Exit codes: 0 ok, 1 error, 2 usage (unknown command or flag), 3 agent launch needs `--yes`, 10 `upgrade --check` found an upgrade due. ## Manual install (same result, by hand) ```sh # 1. Rust + tools curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh cargo install --locked sea-orm-cli cargo-nextest cargo-watch # 2. Framework (verify the checksum yourself) V=0.3.0 # see https://github.com/rust-off-rails/rustoffrails/releases curl -fsSLO https://github.com/rust-off-rails/rustoffrails/releases/download/v$V/rustoffrails.tar.gz curl -fsSLO https://github.com/rust-off-rails/rustoffrails/releases/download/v$V/checksums.txt shasum -a 256 -c checksums.txt --ignore-missing mkdir -p ~/.offrails && tar -xmzf rustoffrails.tar.gz -C ~/.offrails && mv ~/.offrails/rustoffrails ~/.offrails/$V && ln -sfn $V ~/.offrails/current # 3. Claude Code plugin (or skip and point any agent at ~/.offrails/current/AGENTS.md) claude plugin marketplace add rust-off-rails/rustoffrails claude plugin install offrails@offrails ``` You can also skip the installer entirely: clone the repo and tell your agent `Read /path/to/rustoffrails/AGENTS.md and make me a new app called bookshelf`. ## Uninstall `offrails uninstall`, then optionally `claude plugin uninstall offrails@offrails && claude plugin marketplace remove offrails`. Rust and the cargo tools stay; remove them with `rustup self uninstall` if you want. ================================================================ FILE: AGENTS.md (https://rustoffrails.org/docs/agents.md) ================================================================ # AGENTS.md: Rust off Rails You are building (or maintaining) a **Rust off Rails** application. This file is your entry point. It is written for you, the agent, not for humans. ## Prime directives 1. **All code is written by AI: you write all of it, and humans write none.** This is the framework's founding rule ([doctrine §0](doctrine.md#0-all-code-is-written-by-ai)). Humans supply intent as Markdown in `product/`. If you find code a human edited by hand, keep it working, record the intent it expresses in the relevant spec, and continue. Never ask a human to write or paste code. 2. **Start app code from a snippet or a generator (both tested, both yours once copied); hand-write the rest.** A [snippet](template/snippets/README.md) is small working code to read or copy (`cargo offrails snippets`, `snippet show `, `snippet add `): a feed, slugs, Markdown, a pager. A [generator](generators/README.md) writes a feature to your names and fields (`cargo offrails g --help`): a CRUD resource, a model, a page with a path param, an API, a job. Neither is mandatory; when neither fits, or editing one would be more work than writing, write it from the guides. Kernel pieces (mail, jobs, sign-in, sockets) come from parts (`cargo offrails parts`, `cargo offrails add `). Whatever you start from: small files per feature, one marked line per shared spot (`// posts`), then the `// APP:` lines. Pages are built from the component kit (`{% import "components/ui.html" as ui %}`, one of five layouts; generated and snippet pages already are: [views](guides/views.md#components)), and what you built is proven with `BASE=… cargo offrails prove` (screenshots and checks of every page, `docs/proof/PROOF.md`: [testing](guides/testing.md#proof-cargo-offrails-prove)). 3. **Do not deliberate on settled questions.** If these docs answer it, follow the answer. Do not compare alternatives, propose other crates, or "consider" a different layout. Opinions here beat your priors. When a guide gives a template or a whole file, copy it and change only the parts it marks. 4. **The compiler is your reviewer, and the test suite is the second one.** Prefer designs where mistakes become compile errors: typed SeaORM queries over SQL strings, Askama over runtime templates, `ts-rs` types over hand-written TS, enums over strings. What types can't prove, tests prove on **both** SQLite and Postgres. 5. **Never type a crate version from memory.** Use `cargo add`. Before using a crate API you are not certain of, check the resolved version in `Cargo.lock` and read its docs (`cargo doc --open` or docs.rs for that exact version). API drift between versions is the main way agents break this stack. 6. **Explicit beats magic.** You don't pay for boilerplate, so write it. No proc-macro frameworks, no reflection, no hidden callbacks. 7. **Green or it didn't happen.** A task is done only when the [Definition of Done](workflow.md#definition-of-done) passes, starting with `sh .offrails/tools/offrails-check`. Every item has a command; run it, don't eyeball it. 8. **Leave a trail.** Every non-obvious decision goes in `docs/decisions.md` in the app (one dated line each). The next agent has no memory but yours. 9. **Copy what's built; write what's yours.** New apps start from the tested [template](template/README.md) (`offrails-gen new`), and kernel needs are met by its parts (`cargo offrails add `), never retyped from a guide. App-specific code (models, controllers, views, jobs) starts from a snippet, a generator or the guides (directive 2). Once copied or generated, the code is the app's: you adapt it and own it. ## Check the frontend mode first The app's root `AGENTS.md` declares `FRONTEND_MODE: htmx | inertia | spa | api` (plus `ISLANDS: yes` if used) and, when the app has a JS UI (`inertia`, `spa` or islands), `UI_LIBRARY: react | svelte`. Every guide has sections per mode, and the JS guides have sections per library. Follow only the ones that match. Never switch modes unless a human asks, and record it in `docs/decisions.md` if they do. It also declares `API: none | rest | graphql | both`: a public, versioned API for other programs, independent of the UI mode ([api](guides/api.md)); `api` mode always has one. ## Load order **Start with [`CHEATSHEET.md`](CHEATSHEET.md)** (two pages: stack, layout, naming, the Definition of Done, security non-negotiables, the costliest gotchas). Then read a full guide **only when your task touches its topic** (the table below), and inside a guide, read its **TL;DR** first and stop when it answers your question. Read these before your first change in a session: 1. [`interview.md`](interview.md): **before building anything new**, ask the right questions 2. [`doctrine.md`](doctrine.md): why things are the way they are (short) 3. [`structure.md`](structure.md): where every file goes 4. [`stack.md`](stack.md): which crates, and which are banned 5. [`workflow.md`](workflow.md): the loop and the Definition of Done Read on demand: | When the task involves… | Read | |--------------------------------------------|----------------------------------------| | "How would Rails do X?" | [`RAILS_MAP.md`](RAILS_MAP.md) | | Tables, queries, validations, associations | [`guides/models.md`](guides/models.md), [`guides/migrations.md`](guides/migrations.md) | | SQLite vs Postgres, connections, portability | [`guides/database.md`](guides/database.md) | | URLs, handlers, params, flash | [`guides/routing.md`](guides/routing.md), [`guides/controllers.md`](guides/controllers.md) | | HTML, layouts, components, brand, partials, forms, htmx (`htmx` mode) | [`guides/views.md`](guides/views.md), [`guides/frontend-htmx.md`](guides/frontend-htmx.md) | | React or Svelte pages with server routing (`inertia` mode) | [`guides/frontend-inertia.md`](guides/frontend-inertia.md) (+ [`guides/frontend-svelte.md`](guides/frontend-svelte.md) when `UI_LIBRARY: svelte`) | | SPA + JSON API (`spa` mode) or islands | [`guides/frontend-react.md`](guides/frontend-react.md) (React) or [`guides/frontend-svelte.md`](guides/frontend-svelte.md) (Svelte: SvelteKit SPA), [`guides/controllers.md#json-api`](guides/controllers.md#json-api) | | API-only apps (`api` mode), a public API (`API` header), tokens, CORS, OpenAPI | [`guides/api.md`](guides/api.md) | | GraphQL (`API: graphql \| both`) | [`guides/graphql.md`](guides/graphql.md) | | Login, sessions, permissions | [`guides/auth.md`](guides/auth.md), [`guides/security.md`](guides/security.md) | | Background work, scheduled work | [`guides/jobs.md`](guides/jobs.md) | | Jobs dashboard, `app jobs`, inspecting failures | [`guides/jobs-dashboard.md`](guides/jobs-dashboard.md) | | `/dev` tools, dev mailbox, checking the running app | [`guides/dev-tools.md`](guides/dev-tools.md) | | Email | [`guides/mailers.md`](guides/mailers.md) | | Live updates (signals) | [`guides/realtime.md`](guides/realtime.md) | | Chat, presence, websockets, multi-node realtime | [`guides/channels.md`](guides/channels.md) | | File uploads | [`guides/storage.md`](guides/storage.md) | | Caching | [`guides/caching.md`](guides/caching.md) | | Env vars, secrets, environments | [`guides/config.md`](guides/config.md) | | Errors, logging, observability | [`guides/errors-and-logging.md`](guides/errors-and-logging.md) | | Tests, browser smokes, `cargo offrails prove` | [`guides/testing.md`](guides/testing.md) | | Shipping it, `docker compose`, direct TLS | [`guides/deployment.md`](guides/deployment.md) | | Running a `rails generate` equivalent: `cargo offrails g ` | [`generators/README.md`](generators/README.md) | | A recipe to copy (feed, slugs, Markdown, pager, theme toggle, backdated seeds, public page by slug): `cargo offrails snippets` | [`template/snippets/README.md`](template/snippets/README.md) | | Upgrading the app to a newer framework release | [`generators/upgrade.md`](generators/upgrade.md) | | Starting an app from the template; parts (mailer, jobs, auth, realtime, dev dashboard): proposing, adding, anchors | [`template/README.md`](template/README.md), [`guides/samples.md`](guides/samples.md) | | Commands (`rails s`, `db:migrate`, …) | [`cli.md`](cli.md) | ## Where the framework lives inside an app `generators/new.md` (after the interview) copies `template/app/` into the new app and vendors this directory into it at `.offrails/` (so `.offrails/template/` holds the template and parts the app came from, for `add-part` and upgrades). The app's root `AGENTS.md` (from `blueprints/AGENTS.md`) points here. Treat `.offrails/` as read-only. To deviate from it, record the deviation in `docs/decisions.md` and say so in your final message to the human. ## When the docs are silent 1. Find the closest Rails convention in `RAILS_MAP.md` and do the Rust equivalent. 2. If there is none, choose the most boring option: fewest crates, most explicit code, most compile-time checking. 3. Record the choice in `docs/decisions.md`. ================================================================ FILE: CHEATSHEET.md (https://rustoffrails.org/docs/cheatsheet.md) ================================================================ # Cheatsheet The rules agents need most, on two pages. Read this first; open a full guide only when your task touches its topic ([AGENTS.md](AGENTS.md#load-order) maps topics to guides). When this page and a guide disagree, the guide wins. ## Start from the template ([template](template/README.md)) - **New app:** after the interview, `cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" --target-dir /target/offrails-gen -- new "" --parts ` ([new](generators/new.md)). Never retype the skeleton. - **Parts:** propose the ones the spec implies (dev-dashboard always; mailer+dev-mailbox for email; jobs+jobs-dashboard for slow/scheduled work; auth-core+method for sign-in; realtime for pushed updates), link , the human confirms. Later: `cargo offrails parts`, `cargo offrails add `, `cargo offrails update-part --framework ` (3-way merge). Check for a part before hand-writing kernel code. - **Features: start from a snippet or a generator, hand-write the rest.** Both are tested, both are yours once copied, neither is mandatory. Snippets: `cargo offrails snippets`, `snippet show ` (read or copy), `snippet add [--as ]` / `snippet remove ` ([snippets](template/snippets/README.md)). Generators: `cargo offrails g resource posts title:string body:text published_at:datetime:optional` (also `migration`, `model`, `controller` (path params: `show:/blog/{slug}`), `page`, `api`, `job`, `mailer`, `channel`, `test`; `cargo offrails g --help`), `--dry-run` shows the plan, `cargo offrails destroy ` undoes it ([generators](generators/README.md)). **Commit after each run**, then edit the `// APP:` lines and add the spec's tests. - **Pages from the component kit:** `{% import "components/ui.html" as ui %}` then `ui::page_header`, `ui::table`, `ui::field`, `ui::form_actions`, `ui::confirm_button`… (every one at `/dev/components`; signatures: [views](guides/views.md#components)), in a layout (`layouts/application.html` top nav, `sidebar`, `marketing`, `auth`, `docs`; generators take `--layout`). Generated and snippet pages use them already. Colors, fonts, logo: `brand.toml` + `cargo offrails brand`. - **Small files, marked lines.** A feature lives in its own folders (`src/controllers/posts/`, `src/models/post/`, `src/routes/posts.rs`, `templates/posts/`, `tests/app/posts/`); a shared file gets one line per spot, marked `// posts`. Write hand-written features the same way. - The copied code is the app's: adapt `// APP:` lines, keep `// SECURITY:`/`// INVARIANT:` lines and their tests. ## Stack, one crate per job ([stack.md](stack.md)) - **Web:** axum 0.8 on hyper (`/{id}`, never `/:id`) · tower-http (CsrfLayer, trace, compression, timeout, catch-panic) · tokio. - **Data:** SeaORM 2 (SQLite dev/test + Postgres prod, same code) · `sea-orm-migration` in `migration/` · `i64` ids · chrono `DateTimeUtc`. - **Views:** Askama (htmx mode, mailers, kernel pages) · Tailwind standalone CLI · htmx 2 + sse ext. JS UIs: Vite + pnpm + React or Svelte, ts-rs types (`TS_RS_LARGE_INT=number`). - **Other:** lettre (`default-features = false`) · moka · croner · argon2 · object_store · pulldown-cmark + ammonia · utoipa / async-graphql (API axis). - **Banned:** Diesel, raw sqlx, Actix/Rocket/Poem/Loco/Leptos/Dioxus/Yew, Tera/Handlebars/minijinja, `askama_axum`, `lazy_static`/`once_cell`/`async-trait`/`log` as direct deps, Redis/Kafka/RabbitMQ, JWT/localStorage tokens, npm/yarn/Jest/ESLint, axios, Redux, CSS-in-JS, SSR JS servers, Node in htmx mode (except islands). `cargo deny` enforces it. - **Versions:** `cargo add` / `pnpm add`, never from memory; check `Cargo.lock` + docs.rs for the resolved version. ## Layout ([structure.md](structure.md)) ``` AGENTS.md CLAUDE.md .offrails/ (read-only) product/ (human specs) docs/decisions.md src/{main,lib,config,state,routes,error}.rs src/{models,controllers,views,routes,jobs,mailers,channels,policies,tasks,kernel}/ migration/ templates/ static/ web/ (TS modes) tests/app/ (ONE test binary) bin/{setup,dev,docker-entrypoint} ``` **Naming:** tables plural snake (`blog_posts`) · model module singular · `Params` (untrusted) → `validate()` → `New` · controllers plural, 7 Rails actions · routes plural kebab (`/blog-posts/{id}`) · jobs `Job` · FKs `_id` · timestamps `created_at`/`updated_at` · prefer `published_at` over booleans · env vars `SCREAMING_SNAKE`, app ones `APP_`. ## Definition of Done ([workflow.md](workflow.md#definition-of-done)) ```sh cargo offrails verify # after every slice: fmt --check, CSS, clippy, one test build, SQLite + Postgres at once (seconds) cargo offrails verify --full # once, at the end: + offrails-check, coverage ≥80% (≥90% models + policies), cargo deny, # gitleaks, no-unwrap review, routes, TS checks (web/), browser smoke, `prove` (BASE=…) BASE=… cargo offrails prove # every page: screenshots (desktop/mobile × light/dark), console, CSP, axe, links, overflow # → docs/proof/PROOF.md; link it in your final message cargo nextest run # while a test is red: just that test (never the full DoD per edit) ``` Plus (each has its command in the DoD table): every `AC--` cited by a test · rollback then migrate works · no `unwrap`/`expect` outside tests and startup · no raw SQL outside `src/kernel/` · axe a11y suite in light **and** dark ([§10](guides/test-standards.md#10-accessibility-wcag-22-aa-in-every-theme)) · `theme_test` green ([§11](guides/test-standards.md#11-theme-and-css-guards)) · UI changes checked in a real browser, login with no certificate overrides ([§9](guides/test-standards.md#9-real-browser-smoke)) · decisions logged. ## Security non-negotiables ([security.md](guides/security.md#non-negotiables)) Never weaken without a decision entry **and** the human's OK: strict CSP (no `unsafe-inline`/`unsafe-eval`) · the full header matrix on every response · CSRF/Origin checks (WebSocket handshake too) · escaping (raw HTML only via ammonia) · authorization on every route, join and push · tenant scoping · rate limits on login, magic links, tokens, email, sockets · tokens random, digest-only, expiring, minted inside jobs · session revocation closes sockets and streams too · cookies HttpOnly + SameSite=Lax + Secure, `__Host-` session in prod · secrets only in env · cargo-deny green · development reachable from this machine only: `serve` binds `config.bind` (127.0.0.1 unless `APP_BIND`), `kernel::hosts` refuses foreign `Host` names (DNS rebinding), an unset `APP_ENV` refuses to boot · `APP_TRUSTED_PROXY=true` (+ `_HOPS`) behind every proxy, or all users share one rate-limit key. ## Gotchas that cost the most ([full lists in each guide](AGENTS.md#load-order)) 1. **SQLite transactions:** use `kernel::db::begin` (`BEGIN IMMEDIATE`). A deferred one fails with "database is locked" when the job worker writes ([database](guides/database.md)). 2. **Committed migrations are append-only.** A repair migration's `down` restores the predecessor's schema ([migrations](guides/migrations.md#rules)). 3. **Postgres:** a caught unique violation aborts the transaction. Use `on_conflict_do_nothing` / `try_insert` inside transactions ([models](guides/models.md)). 4. **`hx-boost="false"`** on every link into kernel pages (`/dev`, mailbox, `/admin/jobs`), or they render unstyled ([dev-tools](guides/dev-tools.md#rules)). 5. **Page JS loads from the layout and binds by delegation on `document`**: never in a page's `{% block head %}`, never on `DOMContentLoaded`. A boosted link runs nothing from the next page's ``, so the button works on a fresh load and is dead after a click ([frontend-htmx](guides/frontend-htmx.md#boosted-navigation); caught by `smoke-boosted.mjs`). **htmx sse extension in the layout** too; one `sse-connect` per page; never reconnect by editing the attribute; `hx-sync` on live regions ([realtime](guides/realtime.md#client-side)). 6. **htmx history cache off** (`historyCacheSize: 0`, `refreshOnHistoryMiss: true`) plus the `pageshow` reload guard ([frontend-htmx](guides/frontend-htmx.md)). 7. **Signals carry a cursor** (`?since=`); without it, anything sent during a disconnect is lost ([realtime](guides/realtime.md#resume)). 8. **SSE events need non-empty data**, or browsers drop them. 9. **No secrets in job args.** Jobs mint tokens themselves; `/admin/jobs` shows args ([mailers](guides/mailers.md)). 10. **Route params are whole segments:** `/{id}.eml` panics at startup; use `/{id}/message.eml` ([routing](guides/routing.md)). 11. **`TS_RS_LARGE_INT=number`**, or ids become `bigint` in TS ([frontend-inertia](guides/frontend-inertia.md)). 12. **React type-check is `tsc -b`**, never `tsc --noEmit` (it passes with errors under project references). 13. **Read emails via `/dev/mailbox/latest.json`**, never by grepping raw `.eml` (quoted-printable breaks links). 14. **Never `pkill`/`killall` by name.** Stop the PID file you started: `kill "$(cat storage/pids/dev-$PORT.pid)"` (`bin/dev` writes it and stops its whole tree) ([cli](cli.md#running-the-app-from-an-agent)). 15. **Lighthouse checks one color scheme.** Run axe in light and dark ([§10](guides/test-standards.md#10-accessibility-wcag-22-aa-in-every-theme)). 16. **Live pages need HTTP/2:** over HTTP/1.1, realtime breaks at ~6 tabs. `bin/dev` serves HTTPS with a trusted (mkcert) certificate; size per-user connection caps for tabs (128) ([realtime](guides/realtime.md#many-tabs-stall-http11-vs-http2)). 17. **Never select files by loose prefix, never depend on `HashMap` order, never hash files per request.** `"vendor/htmx-"` also matched `htmx-ext-sse`: pages randomly had no htmx. Exact keys, a uniqueness test, one catalog at boot ([frontend-htmx](guides/frontend-htmx.md#assets--cache-busting-kernelassetsrs)). 18. **Every lookup is scoped to the actor:** `board::find_for(db, &user, id)`; raw `find` is `pub(in crate::models)` so a controller can't compile an unscoped call ([models](guides/models.md#scoped-lookups-tenancy)). 19. **Never an untrusted certificate in a browser:** after "Proceed (unsafe)" Chrome drops `Set-Cookie`, so login bounces. No mkcert → `bin/dev` serves http and says so; never `--ignore-certificate-errors` in a smoke ([dev-tools](guides/dev-tools.md#https-in-development)). 20. **Every light surface, dark text and faint tone has its `dark:` partner** in the same class list (palette: [views](guides/views.md#dark-mode)); a template edited without Tailwind running ships missing classes. `theme_test.rs` catches both ([§11](guides/test-standards.md#11-theme-and-css-guards)). 21. **The thundering herd:** N watchers re-fetching after a signal = N renders per change. Render once per version (`try_get_with((stream, version))`, the version bumped in `poll_once`, never at write time); pub/sub senders are per stream ([realtime](guides/realtime.md#render-once-per-version-the-thundering-herd)). 22. **Overload is 503 + `Retry-After`, never 408:** pool `acquire_timeout` 5 s, the `TimeoutLayer` status 503, live re-fetches gated by `X-Live-Refresh`; shutdown drains at most `APP_SHUTDOWN_DRAIN_SECS` ([errors](guides/errors-and-logging.md#response-mapping)). 23. **Never `location.reload()` after a mutation:** move the DOM optimistically, then `htmx.ajax` the live region ([frontend-htmx](guides/frontend-htmx.md#no-full-reload-after-a-mutation)). 24. **Ask the running app before guessing:** `curl -s $URL/dev/introspect.json` (migrations, CSS staleness, protocol, streams), development only ([dev-tools](guides/dev-tools.md#introspection)). 25. **Ids are 64-bit:** `big_pk_auto` and `big_integer` for every `id`/`*_id`, `i64` in entities. A 32-bit id passes on SQLite and is a 500 on every Postgres write; `TestApp` reads `TEST_DATABASE_URL` so the suite runs there (`offrails-check` `[ids]`, `[postgres]`; [migrations](guides/migrations.md#ids-are-64-bit)). 26. **`assert_text` escapes its argument like Askama** (`'` → `'`), so it finds text that came through `{{ … }}`; a literal apostrophe typed in a template stays `'` and needs `assert_contains` ([testing](guides/testing.md#gotchas-agents-get-wrong)). 27. **Spans, one rule:** `#[tracing::instrument(skip_all)]` on actions and public model/service fns; `level = "debug", skip_all` on helpers called per row or in a loop; never without `skip_all` ([errors](guides/errors-and-logging.md#spans)). 28. **No `hx-trigger` filters** (`keyup[key=='Enter']`): they need eval, `allowEval:false` (the CSP) turns them into a trigger that silently never fires. A plain event, or a delegated listener in `static/app.js` calling `htmx.trigger` (`offrails-check` `[htmx]`; [frontend-htmx](guides/frontend-htmx.md#gotchas-agents-get-wrong)). ## Commands ([cli.md](cli.md)) ``` app serve | worker | health | routes app db migrate | rollback [--steps N] | status | seed | setup | reset | console app jobs stats|list|show|retry|cancel|delete|run|pause|resume|prune app task [args…] app introspect bin/setup bin/dev docker compose up offrails new | check | upgrade [--check] | update | doctor ``` ================================================================ FILE: interview.md (https://rustoffrails.org/docs/interview.md) ================================================================ # The Interview: asking the right questions Rails asked its questions through `rails new` flags. Off Rails asks them in conversation. Good questions are the difference between building the right app and building *an* app. ## Principles 1. **Ask about the product, never about the stack's internals.** "Should users be able to delete their account?" is a good question. "Which ORM?" is never asked, because `stack.md` answers it. 2. **Every question has a recommended default,** labeled `(Recommended)`, listed first. "You decide" always means: take the defaults. 3. **Batch questions; minimize round-trips.** At most 3 rounds for a new app. With a structured-question tool (Claude Code: `AskUserQuestion`), send up to 4 questions per call, which is the tool's limit. Without one, send a single numbered list (Rounds 1 and 2 may be combined, up to 8 questions) with options and defaults, so the human can answer `1b 2a 3 default`. 4. **Ask only what changes the build.** If the answer wouldn't change the data model, security, money handling or something irreversible, assume and record the assumption instead. 5. **Write the answers down.** Answers go to `product/README.md` (the "Decisions" section) and, for the technical ones, into the app's `AGENTS.md` header. A question asked twice is a bug. ## New app interview ### Round 1: the product (free text is fine) 1. **What is it, and who uses it?** One or two sentences. (Infer the rest, then confirm in Round 3.) 2. **Accounts:** a) Magic link: email a one-time login link, no passwords; anyone can sign up (Recommended for consumer apps and most new products) b) Email + password with public sign-up (pick this if people can't rely on email access when logging in) c) Invite-only: an admin creates accounts (internal tools; login by password, or by magic link if you say so) d) No accounts (public site) Records as `ACCOUNTS:` the sign-in methods joined by `+`, then `invite` when only invited people get in: a) `magic-link`, b) `password`, c) `password invite` (or `magic-link invite`), d) `none`; both methods: `password+magic-link` ([auth variants](guides/auth.md#variants-accounts-in-the-apps-agentsmd)). Invite-only is a switch, not extra work: `new --invite`, or `DEFAULT_SIGNUP = Signup::Invite` in `src/config.rs`. Magic links need a real email provider before launch, so ask Round 2 Q4 with that in mind; the dev mailbox covers development. 3. **Who owns data?** a) Each user owns their own records (Recommended) b) Teams/workspaces: users belong to an organization and share its records (multi-tenant) c) Everything is shared by all logged-in users Records as `TENANCY: per-user | teams | shared`. With no accounts, skip this question and record `TENANCY: none`. 4. **Roles?** a) None (Recommended) b) Admin + member c) Custom (describe) ### Round 2: the shape of the build 1. **Frontend:** a) `htmx`: server-rendered HTML, minimal JavaScript, fastest to build and change (Recommended) b) `inertia`: React or Svelte + TypeScript pages, still with Rails-style server routing and controllers (pick this if you want a JS UI) c) `spa`: separate single-page app + JSON API (pick only if the UI must be a standalone app) d) `api`: no UI at all, only an API for other programs (mobile apps, partners, scripts), like `rails new --api` **API for other programs** (fold into the same message; ask when the answer is `api`, or when the description mentions a mobile app, partners, integrations or "an API"): a) None (Recommended for UI apps) b) REST + OpenAPI (Recommended for `api`) c) GraphQL d) Both. Record it as `API: none | rest | graphql | both` ([api](guides/api.md)). `api` mode never records `none`. **UI library** (ask only when the answer is `inertia`, `spa`, or htmx with islands; fold it into the same message): a) React (Recommended: the largest ecosystem) b) Svelte (smaller bundles, less code). Record it as `UI_LIBRARY`. If the human names a library while answering the frontend question ("Svelte please"), don't ask again. 2. **Production database:** a) Postgres, managed by the host (Recommended) b) SQLite on the server's disk (single server, cheapest, Rails 8 style) 3. **Deploy target:** a) Decide later. I'll make it Docker-ready (Recommended if unsure) b) Fly.io c) Your own server via Kamal d) Railway e) Render 4. **Email (production):** a) Not needed yet (Recommended) b) Yes, provider decided later (logged only in production until then) c) Yes, using: ___. In development every email goes to the dev mailbox (/dev/mailbox) regardless ### Round 3: features (ask only those the description hints at) - **Files:** "Will users upload files (avatars, attachments)? Size limits?" - **Live updates:** "Should other users see changes without refreshing?" If they mention chat, "who's online", typing indicators or multiplayer, that's a channel room ([channels](guides/channels.md)), not just a signal. Ask "Will this run on more than one server?" only if they expect heavy realtime traffic; the default is one node. - **Scheduled work:** "Anything that happens on a schedule (digests, reminders, cleanups)?" - **Payments:** "Do you take payments? One-off or subscriptions?" (Security-relevant: always ask if hinted.) - **Search:** "Do users need to search? Simple title search or full-text?" - **Admin area:** "Do admins need screens to manage users/records?" - **Languages:** "English only?" (Default yes.) ### Then: confirm the plan (once, before generating) Present a compact plan and wait for a yes: ``` Bookshelf: frontend htmx, Postgres in prod (SQLite locally), deploy later Accounts: email+password, public sign-up; each user owns their books Resources: Book title, author, published_on (date), rating (1–5), belongs to User Review body, stars (1–5), belongs to Book + User Pages: books index/show/new/edit, reviews inline on book page Jobs: email book owner on new review Assumed: reviews editable by author only; deleting a book deletes its reviews ``` **Parts, proposed with the plan (the default path).** Every app starts from the tested template, plus the parts its product needs: tested, security-reviewed code for the mailer and dev mailbox, the job queue and its dashboard, accounts (password, magic link + code, TOTP two-factor, an account security page), realtime channels with presence, and a LiveDashboard-style dev dashboard ([template → choosing parts](template/README.md#choosing-parts)). **You decide which ones from what the human said**, using that table: `dev-dashboard` for every app; `mailer` + `dev-mailbox` when anything sends email (sign-in links count); `jobs` + `jobs-dashboard` when anything is slow, retried or scheduled; `auth-core` + the method matching `ACCOUNTS`; `realtime` for chat, presence or pushed updates. Nothing the spec doesn't imply. Put the list in the plan, one line of reason each, with the link, and let the human confirm or adjust in the same yes: ``` Parts: dev-dashboard (every app: traces, SQL, logs in development) mailer, dev-mailbox (magic-link sign-in sends email; /dev/mailbox shows it) auth-core, auth-magic-link (ACCOUNTS: magic-link) jobs, jobs-dashboard (the weekly digest is scheduled) See every screen first: https://github.com/rust-off-rails/samples ``` The recommended answer is yes. A human who wants a part built from the guides instead, or no part at all, says so; record that answer in `product/README.md` → Decisions, and `generators/new.md` takes `--parts` accordingly. Parts left out now can be added any time with one command (`cargo offrails add-part `), and the agent proposes them again when a feature needs one ([workflow](workflow.md#when-the-product-grows-a-need-add-the-part)). ## Feature-level questions (existing app) When a human asks for a feature, check this list. **Always ask** (unless the spec already answers it) if the feature touches: | Topic | The question | |-------|--------------| | Authorization | "Who can see this? Who can change it?" | | Deletion | "When X is deleted, what happens to its Ys? Can it be undone?" (Soft-delete vs cascade) | | Money | "Amounts, currency, rounding, refunds?" | | Outbound messages | "Who gets emailed/notified, and can they opt out?" | | Personal data | "Is any of this sensitive? Retention?" | | Uniqueness/identity | "Can two Xs have the same name?" (only when ambiguous) | | Irreversible ops | Migrations that drop data, bulk updates, external API calls with side effects | **Never ask** about crate choice, file layout, naming, test strategy, error handling, or styling details beyond "any design references?" These are all answered by the framework or by sensible defaults. ## Answers relayed by someone else Sometimes the human isn't in the conversation: another agent, a ticket or a brief hands you the answers. Treat that as the human's answers **only for what it explicitly covers**: record each one in `product/README.md` → Decisions as "relayed by ", take the recommended defaults for everything it doesn't cover (recorded as assumptions), and still show the plan. Never invent an answer to a question that changes money, deletion or security; mark it an open question in the spec and proceed with the safest default. ## Recording After any interview, update the files below. For a **new app** they don't exist yet: keep the answers in the conversation and write them when `generators/new.md` step 2 creates the files. - `product/README.md` → `## Decisions` (product answers, with date) - `AGENTS.md` (app root) → `FRONTEND_MODE`, `UI_LIBRARY`, `API`, `PROD_DATABASE`, `DEPLOY_TARGET`, `EMAIL` (development always delivers to the dev mailbox, so `EMAIL` records only the production choice: `dev: local mailbox (/dev/mailbox); prod: smtp via Postmark` or `…; prod: not yet (logged only)`. Never write "logged locally" or "files in storage/") - `product/features/.md` → acceptance criteria and "Assumptions" for anything you assumed ================================================================ FILE: doctrine.md (https://rustoffrails.org/docs/doctrine.md) ================================================================ # The Off Rails Doctrine The Rails Doctrine assumed a human at the keyboard. We kept its pillars and rewrote each one for an agent. ## 0. All code is written by AI This is the rule the others rest on. **Every line of code in an Off Rails app is written by an AI agent:** Rust, SQL, HTML, CSS, TypeScript, Svelte, YAML, Dockerfiles and scripts. Humans write intent (specs in `product/`, answers in the interview, feedback in conversation) and never code. It isn't a style preference. It's what makes the rest of this doctrine work: - boilerplate is free, so explicit beats magic; - conventions are enforced by the reader that matters, the agent; - specs stay the source of truth, because code is always regenerated from intent, never hand-patched around it. When a human edits code anyway, the agent doesn't fight it. It keeps the change working, records the intent behind it in the spec, and from then on the agent owns that code again ([workflow](workflow.md#human-code-edits)). ## 1. Optimize for agent correctness (was: programmer happiness) Humans wanted short code. Agents want **code whose mistakes are caught before production**. Every choice trades terseness for checkability: - Queries are typed Rust (SeaORM columns and entities), not strings. - Templates are type-checked at compile time (Askama), and React props are type-checked against Rust (`ts-rs` → `tsc`). - Routes, params and forms are typed structs, never string bags. - What the type system can't prove, the test suite proves, **on both SQLite and Postgres**. When an agent errs, the tight loop (`cargo check` → error → fix; `cargo nextest run` → failure → fix) catches it in seconds instead of in production. ## 2. Convention over deliberation (was: convention over configuration) Configuration was never the real cost. Decisions were. An agent left to choose will reconsider the ORM, the template engine and the folder layout on every task, and it will choose differently each time. This framework makes those choices once. **The worst outcome is inconsistency, not a suboptimal choice.** ## 3. Ask about the product, decide about the code Agents must ask the human the questions only a human can answer (who can see this, what happens on delete, what does it cost) and must never ask questions the framework already answers. See [`interview.md`](interview.md). ## 4. The menu is omakase (still) One crate per job. See [`stack.md`](stack.md). Substitutions are not taken at the table. The one choice offered is the frontend mode, made once per app. ## 5. One shape Data lives in the database, logic lives in model functions, HTTP lives in handlers, and the UI lives in templates or React pages. Every resource has the same shape, so an agent that has seen one resource has seen them all. ## 6. Explicit is cheap now (was: exalt beautiful code) Rails hid boilerplate behind metaprogramming because typing was expensive. Typing is free now. Reading is what costs, for the next agent and for the human reviewing the diff. So: - No callbacks. If saving a post sends an email, the `create` function enqueues the mailer job, and you can see it. - No macros beyond what blessed crates provide. - Repetition across resources is fine, and greppable. Don't abstract until a third copy *and* a spec reason exist. ## 7. Markdown is the spec; the template is its rendering The guides are the spec: what each part must do, and why. Where Rails would ship a gem (sessions, a job queue, a mailer), Off Rails ships that spec **and its rendering as code you copy**: [`template/app/`](template/README.md), a complete app proven on SQLite and Postgres, and the optional parts (the [samples](guides/samples.md)), added with one command. There is no library and no runtime link back: once copied, the code is the app's, read, adapted and owned by its agent like code it wrote. Humans still write none of it (§0). Snippets in the guides are teaching excerpts; where one disagrees with the template file, the tested template wins, and the guide is the bug to fix. (Until 0.7.0 the framework shipped Markdown only and agents retyped the skeleton from it, token by token. That cost 50+ minutes per app and drifted. 2026-10-17: the skeleton ships as files.) ## 8. Sharp knives, labeled Raw SQL exists behind a labeled escape hatch (`guides/database.md`). `unsafe` is banned. `unwrap()` is banned outside tests and startup. ## 9. Value integrated systems One binary serves HTTP, runs jobs, sends mail, pushes live updates and runs its own migrations. One database holds data, jobs, cache, sessions and pub/sub. **The database is the only infrastructure**: SQLite on a laptop, Postgres in production, same code. (Rails 8 learned this with the Solid trifecta. We start there.) ## 10. Progress over stability, but only through the docs The stack evolves by editing these Markdown files, not by agents improvising. If a blessed crate becomes wrong, change `stack.md`, then regenerate. ## 11. Push up a big tent The human doesn't need to know Rust. They need to know what they want. `product/` specs are the human's interface, written in plain English. ================================================================ FILE: structure.md (https://rustoffrails.org/docs/structure.md) ================================================================ # Structure & Naming Every Off Rails app has this exact layout. Don't add top-level directories. Don't move things. Directories marked with a mode exist only in that mode. ``` myapp/ ├── AGENTS.md # from blueprints/AGENTS.md: mode header + pointer to .offrails/ ├── CLAUDE.md # one line: "@AGENTS.md" ├── .cursor/rules/offrails.mdc # Cursor: always-applied pointer to AGENTS.md ├── .gitattributes # * text=auto eol=lf (from the template): LF on every OS ├── .github/copilot-instructions.md # Copilot: pointer to AGENTS.md ├── .offrails/ # this framework, vendored, read-only ├── product/ # HUMAN-OWNED. Specs in Markdown. The only thing humans edit │ ├── README.md # what the app is, who it's for, ## Decisions │ └── features/*.md # one file per feature (template: blueprints/feature-spec.md) ├── docs/ │ ├── decisions.md # agent-maintained decision log (one dated line per entry) │ ├── routes.md # generated by `app routes` │ ├── openapi.json # API: rest | both: the committed OpenAPI document (a test fails when it drifts) │ └── schema.graphql # API: graphql | both: the committed SDL (a test fails when it drifts) ├── Cargo.toml # workspace root + the app package ├── migration/ # db/migrate: SeaORM migration crate (workspace member) │ └── src/{lib.rs, m__.rs} ├── .env.example # every env var, with dev defaults. .env is git-ignored ├── compose.yaml # the whole app, production-shaped: app (the Dockerfile's image) + db (Postgres); profiles worker, mail ├── Dockerfile ├── .dockerignore # target/, storage/, .env, .git/, bin/tailwindcss, web/node_modules/, web/dist/ ├── config/deploy.yml | fly.toml | … # only the chosen deploy target's file (guides/deployment.md) ├── .github/workflows/ci.yml # fmt, clippy, tests on SQLite AND Postgres, frontend checks, security (cargo-deny, gitleaks), Docker + ZAP baseline ├── .github/dependabot.yml # weekly grouped updates (cargo, github-actions, npm in TS modes) ├── deny.toml # cargo-deny: advisories, licenses, banned crates, sources (security.md) ├── .gitleaks.toml # secret scanning allowlist: build/runtime paths only ├── .zap/rules.tsv # triaged OWASP ZAP baseline rules, each with its reason ├── storage/ # local SQLite DBs + local file uploads (git-ignored) ├── src/ │ ├── main.rs # clap CLI: serve | worker | db … | routes | task … | jobs … | health (the `bin/rails`) │ ├── lib.rs # pub mod declarations only │ ├── config.rs # Config struct, parsed from env once │ ├── state.rs # AppState (Clone) + AppState::new(config, db): db, config, mailer, cache, assets, rate_limit (+ storage, pubsub on first use); select_mailer │ ├── routes.rs # THE router + middleware + paths:: helpers + ROUTES (the app's table + each feature's) with Access levels (config/routes.rb) │ ├── routes/ # one file per feature: .rs = its router(), paths::, ROUTES rows; wired into routes.rs by 4 lines marked `// ` │ ├── error.rs # AppError + IntoResponse + ResponseFlavor + shape_errors middleware │ ├── models/ # app/models: mod.rs (Errors, Page, aliases) + one module per table: /{mod.rs (entity), params.rs, queries.rs} │ │ # (generators and snippets write the folder; a part's or a hand-written model may be one .rs) │ ├── controllers/ # app/controllers: one folder per feature, one file per action (pair) │ │ ├── application.rs # shared extractors: Ctx impl, HxRequest, AdminAccess, CurrentUser, RequireUser (ApplicationController) │ │ ├── errors.rs # not_found: the router fallback │ │ ├── health.rs # GET /up │ │ ├── dev.rs # development only: /dev index, /dev/routes, /dev/system, test buttons, /dev/mailers → Previews redirect │ │ ├── migrations.rs # pending(): the dev pending-migrations page + Run button; /up 503 while pending │ │ ├── posts/ # mod.rs (re-exports), index.rs, show.rs, new.rs (new+create), edit.rs (edit+update), destroy.rs │ │ ├── pages/ # `g page`: mod.rs + .rs per page │ │ ├── api/ # spa (and islands' JSON): handlers at /api. With `API: rest|both`: mod.rs (ApiJson, ApiDoc, ErrorBody) + v1/.rs at /api/v1 │ │ ├── graphql.rs # API: graphql|both: POST /graphql, GraphiQL at /dev/graphiql (development) │ │ └── root.rs # api mode: GET / (JSON index), GET /robots.txt │ ├── views/ # mod.rs (render, Ctx), helpers.rs, then what the client receives, one file per resource │ │ # htmx: Askama template structs (PostsIndex, PostPartial) │ │ # inertia: #[derive(Serialize, TS)] page props (PostsIndexProps) │ │ # spa: #[derive(Serialize, TS)] JSON bodies (PostJson, PostsPage) │ │ # api: #[derive(Serialize, ToSchema)] JSON bodies (PostJson, PostBody, PostsPage + PageInfo) │ ├── graphql/ # API: graphql|both: mod.rs (schema, limits, Viewer, gql_error), query.rs, mutation.rs, types.rs, loaders.rs │ ├── jobs/ # app/jobs: one file per worker + mod.rs (Worker trait, dispatch) + recurring.rs │ ├── mailers/ # app/mailers: mod.rs (deliver_later, deliver_later_until), previews.rs (PREVIEWS), one file per mailer │ ├── channels/ # app/channels: mod.rs (GET /streams/{stream} + authorize_stream; GET /socket, AppRoom, impl Channels) + one authorizer per resource + _room.rs per room │ ├── policies/ # authorization: mod.rs (authorize) + .rs (can_* fns + truth-table tests) │ ├── tasks/ # lib/tasks: `app task `; seed.rs is db/seeds.rb │ └── kernel/ # framework runtime, generated from guide specs (see stack.md#the-kernel-code-you-generate-instead-of-depending-on) │ ├── db.rs, flash.rs, rate_limit.rs, assets.rs, mailer.rs, cache.rs # generated by `new` │ ├── ui.rs # shared chrome for kernel pages (Chrome, KernelFlash, no_store, page) │ ├── jobs/ # mod.rs (tables, Outcome, JobInfo, JobBuilder, Unique), executor.rs (producers, claim, record, drain), │ │ # plugins.rs (Heartbeat, Stager, Lifeline, Pruner, Cron), admin.rs (shared ops), web.rs (/admin/jobs) │ ├── mailbox.rs # Local mailer storage + the dev mailbox UI (/dev/mailbox, Previews tab, JSON) │ └── session.rs, return_to.rs (authentication), pubsub.rs (channel), hub.rs + channels.rs + cluster.rs (first room), storage.rs (attachment), inertia.rs # added by the generator/mode that needs them ├── templates/ # Askama HTML (app/views) │ ├── layouts/application.html # htmx: main layout. inertia: the root HTML shell │ ├── errors/ # htmx: 404.html, 403.html, 500.html (standalone, no layout, no ctx) │ ├── shared/ # htmx: _flash.html, _errors.html, _nav.html, _dev_mailbox_hint.html (authentication) │ ├── posts/ # htmx: index.html, show.html, new.html, edit.html, _form.html, _post.html │ ├── pages/ # htmx: .html per `g page` │ ├── mailers/ # all modes: layout.html (THE mail layout) + /.html and .txt │ └── kernel/ # all modes: layout.html, jobs/ (dashboard), mailbox/, dev/ (Askama + htmx, framework-owned look) ├── assets/app.css # htmx: Tailwind input ├── brand.toml # modes with pages: the look (colors, fonts, radius, logo); `cargo offrails brand` renders it ├── assets/brand.css # generated from brand.toml: the Tailwind theme (never edit) ├── assets/brand/og.svg # generated: the social card source ├── static/ # public/: served at /static (Vite build output is served at /assets from web/dist) │ ├── app.css # htmx: Tailwind output (built, git-ignored) │ ├── favicon.svg, favicon.ico, apple-touch-icon.png, icon-512.png, og.png # modes with pages, from brand.toml (`cargo offrails brand`; `new` runs it) │ ├── kernel/admin.css # all modes: plain CSS for /admin/jobs, /dev, /dev/mailbox │ ├── kernel/admin.js # all modes: reload kernel pages restored from the back/forward cache │ └── vendor/htmx-.min.js # all modes (the dashboard and /dev use it) ├── web/ # inertia | spa | islands: the Vite + TS app (UI_LIBRARY react: .tsx, svelte: .svelte) │ ├── package.json, pnpm-lock.yaml, vite.config.ts, tsconfig.json, biome.json, playwright.config.ts │ ├── e2e/ # inertia | spa: Playwright specs for the critical flows │ └── src/ │ ├── main.tsx # entry: createInertiaApp (inertia) / router + QueryClient (spa). Svelte: main.ts; spa = SvelteKit (src/routes/, src/lib/; see frontend-svelte.md) │ ├── types/ # GENERATED by ts-rs. Never edit. Committed (Docker/CI build web/ before Rust tests) │ ├── inertia.d.ts # inertia: types usePage() from the generated SharedProps/Flash │ ├── test-setup.ts # jest-dom matchers + afterEach(cleanup) (Svelte: jest-dom only; svelteTesting() cleans up) │ ├── paths.ts # mirror of Rust paths:: helpers (inertia/spa). Keep in sync │ ├── pages// # inertia: Index.tsx, Show.tsx, New.tsx, Edit.tsx (or .svelte). ONLY pages (no forms, no tests) │ ├── pages/errors/Show.tsx # inertia: 403/404/500 on Inertia visits │ ├── routes// # spa: same file names, React Router pages (Svelte: SvelteKit +page.svelte files) │ ├── api/ # spa: client.ts (the ONLY fetch caller) + .ts hooks (Svelte: src/lib/api/) │ ├── components/ # shared UI (Layout, Flash…); inertia forms: components//Form.tsx (+ .test.tsx; Svelte: .svelte + .svelte.test.ts) │ └── islands/ # islands: one file per island ├── .cargo/config.toml # [alias] offrails (cargo offrails add-part / list-parts, from the template); inertia/spa also: [env] TS_RS_EXPORT_DIR = { value = "web/src/types", relative = true }, TS_RS_LARGE_INT = "number" ├── .cargo/mutants.toml # cargo-mutants: equivalent-mutant exclusions, each with its reason (test-standards §5) ├── tests/app/ # ONE integration test binary (fast linking) │ ├── main.rs # mod declarations only (`#[allow(dead_code)] mod support;`) │ ├── support.rs # TestApp, TestResponse │ ├── factories.rs # + factories/.rs per model (`mod x; pub use x::*;`, marked) │ ├── smoke_test.rs # generated by `new` │ ├── security_test.rs # access matrix over ROUTES, CSRF (test-standards.md §3) │ ├── headers_test.rs # the header matrix per response class, dev + prod (security.md) │ ├── kernel_jobs_test.rs # generated by `new`: the queue (guides/jobs.md#testing-obantesting-equivalents) │ ├── kernel_web_test.rs # generated by `new`: /admin/jobs, /dev/mailbox, /dev │ ├── accounts_test.rs # authentication generator │ ├── posts/ # a generated resource: mod.rs (submit helper), read_test.rs, write_test.rs, access_test.rs │ └── reports_test.rs # a generated controller (one file); pages/_test.rs per `g page` ├── tests/e2e/ # htmx: dev-only real-browser smoke, created with the first critical flow (authentication or the first scaffold), not by `new` (package.json with @playwright/test, playwright.config.ts, smoke.spec.ts; realtime.spec.ts with channels) └── bin/ ├── setup # first-run: tools, .env, CSS or pnpm install, db setup ├── setup-tailwind # htmx/islands: pinned + checksum-verified Tailwind download ├── dev # migrate, then run everything for development ├── docker-entrypoint # migrate (unless SKIP_MIGRATIONS=true), then exec └── tailwindcss # htmx: standalone binary (git-ignored, fetched by bin/setup-tailwind) ``` ## Naming conventions | Thing | Convention | Example | |-----------------------|------------------------------------|---------| | Table | plural snake_case | `blog_posts` | | Model module / type | singular module, `Model` inside, alias in `models/mod.rs` | `models/blog_post.rs`, `pub type BlogPost = blog_post::Model;` | | Params struct | `Params` | `BlogPostParams` (untrusted input, all `String`/`Option`) | | Validated data | `New` | `NewBlogPost` | | Controller file | plural | `controllers/blog_posts.rs` | | Handler fns | the 7 Rails actions | `index show new create edit update destroy` | | Route path | plural kebab-case | `/blog-posts/{id}/edit`, API: `/api/blog-posts/{id}` | | Template dir (htmx) | plural snake_case | `templates/blog_posts/` | | Partial (htmx) | leading underscore | `templates/blog_posts/_form.html` | | View struct (htmx) | `` / `Partial` | `BlogPostsIndex`, `BlogPostPartial` | | Props (inertia) | `Props` | `BlogPostsIndexProps` | | Inertia component | `"/"` ↔ `web/src/pages//.tsx` (Svelte: `.svelte`) | `"blog_posts/Index"` | | JSON body (spa) | `Json`, `Page` | `BlogPostJson` | | JSON field names | `snake_case` everywhere (no serde renames), so TS matches Rust matches DB | `published_at` | | Foreign key | `_id` (i64) | `author_id` | | Timestamps | `created_at`, `updated_at` (`DateTimeUtc`, set in Rust) | | | Booleans | prefer a nullable timestamp (`published_at`) over `is_published` | | | Job struct | `Job` | `SendWelcomeEmailJob` | | Mailer fn | `::` | `user_mailer::welcome` | | Migration | `m__.rs` (via `cargo offrails g migration`) | `m20261012_120000_create_blog_posts.rs` | | Env vars | `SCREAMING_SNAKE`; app-specific ones prefixed `APP_` | `DATABASE_URL`, `APP_URL` | ## Module rules - `lib.rs` is the crate. `main.rs` only parses CLI and calls into the lib, which keeps everything testable. - Controllers may call models, views, jobs, mailers and policies. - Models call only the database, other models, and `jobs::enqueue`. **A model never imports controllers or views.** - Views are data plus templates. No database access in views, ever (that's how N+1 queries happen). - Jobs and mailers call models. - **The kernel depends only on the app's plumbing:** `error::AppError`, `config` (types and `APP_NAME`), `state::AppState` (kernel routers and extractors take it via `State`/`FromRef`), `models::Errors` and other kernel modules. It never imports feature code: models other than `Errors`, controllers, views, policies, jobs or mailers. App-specific wiring (the job dispatcher, worker config, recurring schedule) lives in `src/jobs/` and is handed to the kernel as values or `fn` pointers (`DispatchFn`, `worker_config`, `&[Recurring]`). - Business processes touching more than two models go in the model that "owns" the process (`models/order.rs::checkout`). There is no `services/` directory. If no model owns it, create a module for the process noun (`models/signup.rs`). ================================================================ FILE: stack.md (https://rustoffrails.org/docs/stack.md) ================================================================ # The Stack (the "Gemfile") One crate per job. Add each one with `cargo add` (and JS packages with `pnpm add`). **Never write version numbers from memory.** ## Selection rubric (how these were chosen; do not re-run it) 1. **The model knows it.** Crates with huge training-data presence produce fewer hallucinated APIs. 2. **Types over strings.** Prefer APIs where mistakes are compile errors. 3. **Portable.** The same code must run on SQLite (local, tests, small prod) and Postgres (prod). 4. **Stable API.** Crates that churn break agent-written code. 5. **Fewer crates.** If ~150 lines of generated code replace a niche crate, generate the code from a spec (see "Kernel" below). ## Blessed crates "Added by" says when a crate enters the app: `new` adds the first group (exact commands in [`generators/new.md`](generators/new.md#workspace--dependencies)); the rest arrive with the generator or first feature that needs them. Adding everything up front costs compile time, and `object_store` brings a second TLS crypto provider (below). | Job | Crate(s) and features | Added by | Notes | |-----|-----------------------|----------|-------| | Async runtime | `tokio` (`full`), `tokio-stream` (`sync`), `futures` | `new` | `tokio-stream` for SSE; `futures` for `catch_unwind` in the job worker | | HTTP framework | `axum` (`multipart`; `ws` with the first channel room), `axum-extra` (`cookie`, `typed-header`, `form`) | `new` | axum 0.8+: path params are `/{id}`, **not** `/:id` | | Middleware | `tower`, `tower-http` (`fs`, `trace`, `compression-gzip`, `set-header`, `timeout`, `request-id`, `catch-panic`, `csrf`) | `new` | `csrf` is tower-http's `CsrfLayer` (the Go 1.25 `Sec-Fetch-Site` scheme), so CSRF is not generated ([security](guides/security.md#csrf)) | | ORM / Active Record | `sea-orm` (`sqlx-sqlite`, `sqlx-postgres`, `runtime-tokio-rustls`, `macros`, `with-chrono`, `with-json`, `with-uuid`) | `new` | One codebase, both backends. `DATABASE_URL` picks the backend at runtime | | Migrations | `sea-orm-migration` (`runtime-tokio-rustls`, `sqlx-sqlite`, `sqlx-postgres`), in the `migration/` crate | `new` | Rust DSL that emits correct DDL per backend. Embedded in the binary | | Templates (`htmx` mode, mailers, kernel pages) | `askama` | `new` | Render with `.render()?` and wrap in `axum::response::Html`. Do **not** use `askama_axum` (removed upstream) | | Serialization | `serde` (`derive`), `serde_json` | `new` | | | Forms with arrays/nesting | `serde_html_form` | `new` | The kernel uses it directly (the jobs dashboard's filter links; `kernel/storage.rs` re-parses multipart fields), so every app has it. `axum-extra`'s `Form` uses its own internal copy | | Time | `chrono` (`serde`) | `new` | `DateTimeUtc` everywhere; local time only in views | | IDs | `i64` primary keys; `uuid` (`v4`, `serde`) for public tokens and test databases | `new` | | | Errors | `thiserror` (`AppError`), `anyhow` (`main.rs`, tasks, jobs, and the `AppError::Internal` catch-all) | `new` | | | Logging/tracing | `tracing`, `tracing-subscriber` (`env-filter`, `json`) | `new` | | | CLI | `clap` (`derive`, `env`) | `new` | | | Config | `dotenvy` (dev only) + env vars parsed into a struct | `new` | No config-file crates | | Randomness / tokens | `rand`, `base64`, `sha2` | `new` | Tokens are random; only their SHA-256 is stored. rand 0.10: `rand::random_range(a..=b)`, `rand::rng()` | | Email | `lettre` with **`default-features = false`** and features `builder`, `smtp-transport`, `pool`, `hostname`, `tokio1-rustls-tls` | `new` | lettre's defaults include `native-tls`; with `tokio1-rustls-tls` on top that's a **compile error**. `cargo add lettre --no-default-features --features …` | | In-process cache, rate limits | `moka` (`future`) | `new` | Shared cache lives in the DB | | Cron expressions | `croner` | `new` | Recurring jobs only | | Password hashing | `argon2` (default features) | `authentication` (password variants) | 0.6 makes its own salt: `Argon2::default().hash_password(pw.as_bytes())`. 0.5's `SaltString::generate(&mut OsRng)` no longer compiles ([auth](guides/auth.md#passwords-password-variants)) | | File storage | `object_store` (`aws`), `bytes` | first `attachment` field | Local disk in dev, memory in tests, S3-compatible in prod. 0.14: `put`/`get`/`delete` live on `ObjectStoreExt` (import it) ([storage](guides/storage.md)) | | Markdown / rich text | `pulldown-cmark`, `ammonia` | first `rich_text` field, or `new` for a content/docs site | Always sanitize with ammonia ([views](guides/views.md#rich-text)) | | TS type export (`inertia`/`spa`/islands) | `ts-rs` (`chrono-impl`, `serde-json-impl`) | `new` (TS modes) | `#[derive(TS)]` on every struct the frontend sees. Never hand-write TS types for server data | | Web server | `axum::serve` on **hyper** (HTTP/1.1 + HTTP/2 cleartext); `axum-server` (`tls-rustls`) + `rustls` (`aws_lc_rs`, `std`, no default features) for direct TLS (h2) | `new`: always, since live pages need h2 in dev (`bin/dev` serves HTTPS) | No app server, no reverse proxy, no Nginx: the binary is the server. TLS terminates at the platform proxy (Fly, Kamal-proxy, Render, Railway); `APP_TLS_CERT`/`APP_TLS_KEY` serve HTTPS directly (h2 via ALPN) for a single box ([deployment](guides/deployment.md#direct-tls-optional)) | | Public REST API docs (`API: rest \| both`) | `utoipa` (`axum_extras`, `chrono`) | `new` when `API` includes rest | OpenAPI 3.1 from annotations, served at `/api/openapi.json`, committed as `docs/openapi.json`. Not `utoipa-scalar`/`utoipa-swagger-ui` ([api](guides/api.md#openapi-api-rest--both)) | | GraphQL (`API: graphql \| both`) | `async-graphql` (`chrono`, `dataloader`), `async-graphql-axum` | `new` when `API` includes graphql | 7.x (8.0 is a release candidate). Depth ≥ 13 or introspection breaks ([graphql](guides/graphql.md)) | | CORS (any `API`) | `tower-http` feature `cors` | `new` when `API` is set | Exact origins from `APP_CORS_ORIGINS`, never `*`, no credentials ([api](guides/api.md#cors)) | | Testing | built-in `#[test]`/`#[tokio::test]`; dev-deps `tower` (`util`), `http-body-util`, `tempfile`, `insta` (`json`, `redactions`) | `new` | `insta` for API contract snapshots ([test-standards](guides/test-standards.md)). Runner: `cargo-nextest` | ### Verified against (2026-10-12) The `bookshelf` reference app (htmx, SQLite + Postgres 18) was generated from these docs with `cargo add` and compiled, linted (`clippy -D warnings`) and tested on both databases with these resolved versions. **This table is orientation, not a pin.** Agents still `cargo add` and read `Cargo.lock`. When a resolved major/minor differs from this table, read that crate's changelog before writing code against it. | Crate | Version | | Crate | Version | |-------|---------|-|-------|---------| | rustc / cargo | 1.99 | | sea-orm, sea-orm-migration, sea-orm-cli | 2.0.4 | | axum | 0.8.9 | | sea-query (via sea-orm) | 1.0.2 | | axum-extra | 0.12.6 | | sqlx (via sea-orm) | 0.9.0 | | tower / tower-http | 0.5.3 / 0.7.1 | | askama | 0.16.1 | | tokio | 1.53.2 | | lettre | 0.11.23 | | serde / serde_json | 1.0.229 / 1.0.154 | | moka | 0.12.16 | | chrono | 0.4.45 | | croner | 4.0.1 | | uuid | 1.28.0 | | rand | 0.10.3 | | clap | 4.6.7 | | base64 | 0.23.1 | | thiserror / anyhow | 2.0.21 / 1.0.104 | | sha2 | 0.11.1 | | tracing / tracing-subscriber | 0.1.44 / 0.3.23 | | serde_html_form | 0.4.1 | | insta | 1.49.0 | | cargo-nextest, cargo-watch | latest via `cargo install --locked` | | tokio-tungstenite (dev, channel tests) | 0.29.0 (match axum's; `cargo add --dev tokio-tungstenite@0.29`) | | | | | ts-rs (inertia branch) | 12.0.1 | | | | | async-graphql, async-graphql-axum (api demo, 2026-10-13) | 7.2.1 | | utoipa (api demo) | 6.0.0 | | axum-server (`tls-rustls`, api demo) | 0.8.0 | | rustls (`aws_lc_rs`) | 0.23.45 | | GraphiQL (CDN, dev page) | 4.1.2 | | @scalar/api-reference (CDN, dev page) | 1.74.0 | | argon2 (auth branch) | 0.6.0 | | object_store / bytes (auth branch) | 0.14.2 / 1.12.1 | Exercised by the reference app's `auth` branch (2026-10-12): `argon2` 0.6.0, `object_store` 0.14.2, `bytes` 1.12. Resolved but not yet exercised: `pulldown-cmark` 0.13, `ammonia` 4.2. (`ts-rs` is exercised on the reference app's `inertia` branch; set `TS_RS_LARGE_INT = "number"`, see [frontend-inertia](guides/frontend-inertia.md#vite-wiring-verified-with-vite-8-vitejsplugin-react-6-tailwind-4).) ### Version gotchas found while verifying - **MSRV-aware resolver.** Edition 2024 resolves dependencies to versions compatible with the workspace's `rust-version`. `sea-orm-cli migrate init` writes `rust-version = "1.85.0"` into `migration/Cargo.toml`, which silently downgrades crates (e.g. `uuid`). Delete that line ([new.md step 3](generators/new.md#workspace--dependencies)). - **sea-query 1.0 (SeaORM 2):** expression methods (`.add()`, `.eq()` on `Expr`) live on the `ExprTrait` trait. Importing it at module level makes `i32::max`/`min` ambiguous, so import it inside the function that needs it. `LIKE … ESCAPE` is `Column::X.like(LikeExpr::new(pattern).escape('\\'))`. - **SeaORM 2 raw statements:** `ConnectionTrait::execute`/`query_one`/`query_all` now take a query *builder*. For a `Statement`, use `execute_raw` / `query_one_raw` / `query_all_raw` (`execute_unprepared(&str)` is unchanged). - **SeaORM SQLite pool:** if `max_connections` isn't set, SeaORM uses **1** for SQLite. `kernel/db.rs` always sets it. - **tower-http 0.7:** `TimeoutLayer::new` is deprecated; use `TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, d)`. The compression layer now answers 406 when a client refuses every encoding. - **Two rustls crypto providers.** `lettre`/`sqlx` use `ring`; `object_store` (via `reqwest` 0.13) brings `aws-lc-rs` (`cargo tree -i aws-lc-rs`). A rustls client built from the *process default* provider would panic with both compiled in ("no process-level CryptoProvider"). **Checked 2026-10-12 with object_store 0.14.2 and lettre 0.11.23: no panic.** Real TLS handshakes to S3 and to SMTPS (port 465) succeeded without `install_default`, because both crates pass their provider explicitly. Re-check after upgrading either crate. If it ever panics, `cargo add rustls --no-default-features --features ring` and call `let _ = rustls::crypto::ring::default_provider().install_default();` first in `main` and `TestApp::new`. (`aws-lc-sys` also needs `cmake` and a C compiler at build time. The `rust:*-trixie` image has them.) ## Databases | Environment | Default | Supported | |-------------|---------|-----------| | Development | SQLite `sqlite://storage/development.sqlite3?mode=rwc` | Postgres | | Test | SQLite in-memory, one DB per test | Postgres (CI matrix, required) | | Production | Postgres | SQLite on a persistent volume (single server, like Rails 8) | Portability rules are in [`guides/database.md`](guides/database.md). In short: everything goes through SeaORM, backend-specific SQL is allowed only in `src/kernel/` or behind an explicit `match` on the backend, and the test suite proves both backends. ## Frontend modes Every app has exactly one `FRONTEND_MODE`, chosen during the [interview](interview.md) and recorded in the app's `AGENTS.md`. Don't mix modes. Islands are the one sanctioned hybrid. Modes with a JS UI (`inertia`, `spa`, islands) also have one `UI_LIBRARY`: **`react`** (default) or **`svelte`** ([frontend-svelte](guides/frontend-svelte.md)). The mode decides the architecture, and the library decides only what `web/` components are written in. | Mode | Choose when | Stack | Guide | |------|-------------|-------|-------| | `htmx` (default) | CRUD apps, dashboards, content sites, internal tools | Askama + **htmx** (vendored) + `sse` extension + **Tailwind standalone CLI**. No Node | [frontend-htmx](guides/frontend-htmx.md) | | `htmx` + islands | Mostly server-rendered, with a few rich widgets (editor, canvas, drag-and-drop) | `htmx`, plus Vite-built React or Svelte islands in `web/` | [frontend-htmx#islands](guides/frontend-htmx.md#islands) | | `inertia` | **The human wants a JS UI (React or Svelte).** App-like UI, with server routing, controllers and sessions kept Rails-style | Inertia protocol (generated `kernel/inertia.rs`) + `@inertiajs/react` or `@inertiajs/svelte` + Vite + TS | [frontend-inertia](guides/frontend-inertia.md) | | `spa` | The UI must be a standalone single-page app (offline-ish, app-shell UX) talking to its own JSON API | Rust JSON API under `/api` + an SPA: React (Vite + React Router + TanStack Query) or Svelte (SvelteKit in SPA mode + TanStack Query) | [frontend-react](guides/frontend-react.md), [frontend-svelte](guides/frontend-svelte.md#spa-mode-sveltekit) | | `api` | **No UI**: the product is an API for mobile apps, partners or scripts (`rails new --api`) | Rust JSON API only: REST `/api/v1` + OpenAPI and/or GraphQL (`API` header), bearer tokens. No templates, Tailwind or `web/`; kernel pages (`/dev`, `/admin/jobs`) still exist | [api](guides/api.md), [graphql](guides/graphql.md) | If the human says "React" or "Svelte" without a reason that points to `spa`, choose `inertia` with that `UI_LIBRARY`. A public API is a separate axis, `API: none | rest | graphql | both`, which any mode can have ([api](guides/api.md)). ### Blessed JS packages (`inertia`, `spa`, islands) Last verified 2026-10-12 (versions are for orientation only; always `pnpm add` without a version). | Job | Package | Verified at | |------------------|---------|-------------| | Runtime | **Node 24 LTS** (≥ 22.22 is the hard floor: React Router 8, jsdom and Vitest 5 refuse older; Node 20 fails). Vite 8's own `engines` says `^20.19.0 \|\| >=22.12.0` (checked in 8.2.2): that's Vite alone, not this stack, so ignore it. The browser smokes need ≥ 22.12 (puppeteer-core 25), which any TS app's Node already meets | 24.21 | | Package manager | **pnpm**, pinned per app via `"packageManager"` in `web/package.json` | 12.x | | Scaffold | `pnpm create vite web --template react-ts`, then convert per [frontend-inertia](guides/frontend-inertia.md) "Vite wiring" (delete demo files, swap the template's oxlint for Biome) | create-vite 9 | | Build/dev server | **Vite** + `@vitejs/plugin-react` (entry via `build.rolldownOptions.input`; `rollupOptions` is deprecated) | 8.3 / 6.1 | | Language | **TypeScript** as pinned by the template, `strict: true`, `noUncheckedIndexedAccess: true`. Type-check with **`tsc -b`** (project references) | 6.0 | | UI | **React** | 19.3 | | Inertia adapter | `@inertiajs/react` + `@inertiajs/core` (same version; `inertia` mode only) | 3.9 | | Routing | `inertia`: server routes. `spa`: **`react-router`** (data/SPA mode, `createBrowserRouter`; `RouterProvider` from `react-router/dom`). Never `react-router-dom` or framework mode | 8.4 | | Server state | `inertia`: page props. `spa`: **TanStack Query** (`@tanstack/react-query`) | 5.104 | | CSS | **Tailwind CSS** via `@tailwindcss/vite` (islands: the standalone CLI instead) | 4.3 | | HTTP | `inertia`: `router`/`useForm` from `@inertiajs/react` (built-in XHR client; v3 dropped axios). `spa`: `fetch` wrapped in `web/src/api/client.ts` | | | Unit tests | **Vitest** + jsdom + `@testing-library/react` + `@testing-library/jest-dom` (`/vitest` entry) | 5.0 / 30 / 16.3 / 7.0 | | E2E (critical flows; required in `inertia`/`spa`) | **Playwright** (`@playwright/test`) | 1.64 | | Lint/format | **Biome** (`biome check --write .` locally, `biome ci .` in CI) | 2.5 | With `UI_LIBRARY: svelte`, these rows replace their React counterparts (details: [frontend-svelte](guides/frontend-svelte.md)): | Job | Package | Verified at | |------------------|---------|-------------| | Scaffold | `inertia`/islands: `pnpm create vite web --template svelte-ts`. `spa`: `pnpm dlx sv create web --template minimal --types ts --no-add-ons` | create-vite 9 / sv 1.1 | | UI | **Svelte 5** (runes only) | 5.57 | | Build plugin | `@sveltejs/vite-plugin-svelte` (`spa`: through `@sveltejs/kit/vite`) | 7.3 | | Inertia adapter | `@inertiajs/svelte` + `@inertiajs/core` | 3.9 | | Routing (`spa`) | **SvelteKit** in SPA mode with `@sveltejs/adapter-static` (`ssr = false`, `prerender = false`, `fallback: "index.html"`). Never SSR or server routes | 3.0 / 4.0 | | Server state (`spa`) | **TanStack Query** (`@tanstack/svelte-query`, accessor API) | 6.3 | | Type-check | **`svelte-check --fail-on-warnings`** (not `tsc`, which can't see `.svelte`) | 4.7 | | Unit tests | Vitest + jsdom + **`@testing-library/svelte`** (with its `svelteTesting()` Vite plugin) + `@testing-library/jest-dom` | 5.4 | | Lint/format | **Biome** with `html.experimentalFullSupportEnabled` (template-aware; no Prettier, no ESLint) | 2.5 | htmx mode (no Node): **htmx 2.x** (`htmx.org@2`; 4.x is not adopted) + **htmx-ext-sse 2.x**, vendored; **Tailwind v4 standalone CLI** pinned by `TAILWIND_VERSION` in `bin/setup-tailwind`. Verified: htmx 2.0.11, htmx-ext-sse 2.2.4, Tailwind 4.3.3. ## Required tooling (install once per machine) ```sh cargo install --locked sea-orm-cli cargo-nextest cargo-watch cargo install --locked cargo-llvm-cov cargo-mutants # coverage gate + weekly mutation run (test-standards.md §5) cargo install --locked cargo-deny # or `brew install cargo-deny` (verified 0.20.2): deny.toml gate # gitleaks (verified 8.30.1: `brew install gitleaks`, or CI's ghcr.io/gitleaks/gitleaks:v8.30.1 image): secrets in history # OWASP ZAP baseline: Docker image ghcr.io/zaproxy/zaproxy:stable (security.md#automated-enforcement) # htmx mode: bin/setup downloads the Tailwind standalone binary to bin/tailwindcss # inertia/react/islands: Node 24 LTS (fnm/nvm/mise/Homebrew), then pnpm: # corepack enable pnpm # Node ≤ 24 ships corepack; Node 25+ doesn't, so there use: npm install -g pnpm # Deploy CLIs only for the chosen target: flyctl, kamal (gem), railway, render — installed when DEPLOY_TARGET is set # Postgres locally is optional: `docker compose up -d db` when testing against it # Optional, any mode: Node ≥ 22.12 + an installed Chrome for .offrails/tools/browser-smoke/run.sh # (puppeteer-core is cached in ~/.cache/offrails, never added to the app; guides/dev-tools.md#browser-smoke) ``` ## The Kernel: code you generate instead of depending on These pieces are small, easy to get wrong in a generic crate, and central to the Rails experience. Generate them into `src/kernel/` from the specs in the guides. The specs are the framework, and the code is your rendering of them. (CSRF used to be here; tower-http 0.7 now ships the same design as `CsrfLayer`, so it's a dependency instead.) | Kernel module | Spec in | Replaces | |------------------------|--------------------------------|----------| | `kernel/db.rs` | `guides/database.md` | `database.yml`, connection setup, SQLite pragmas | | `kernel/jobs/` (`mod.rs`, `admin.rs`, `web.rs`) | `guides/jobs.md`, `guides/jobs-dashboard.md` | Solid Queue / Active Job / Mission Control (Oban + Oban Web, in spirit) | | `kernel/mailbox.rs` | `guides/mailers.md` | letter_opener_web (Swoosh's `/dev/mailbox`) | | `kernel/cache.rs` | `guides/caching.md` | Solid Cache | | `kernel/pubsub.rs` | `guides/realtime.md` | Solid Cable / Action Cable refresh broadcasts (signals; woken by `NOTIFY` or in-process) | | `kernel/hub.rs`, `kernel/channels.rs` | `guides/channels.md` | Action Cable channels / Phoenix Channels + Presence: one WebSocket, topics, room actors | | `kernel/cluster.rs` | `guides/channels.md` | libcluster + Horde: `cluster_nodes` heartbeats, `NOTIFY` bus, rendezvous ownership (Postgres, opt-in) | | `kernel/session.rs` | `guides/auth.md` | Rails 8 authentication sessions | | `kernel/flash.rs` | `guides/controllers.md` | `flash[:notice]` (+ the `_errors` cookie in `inertia` mode) | | `kernel/security.rs` | `guides/security.md` | Rails' default headers + `force_ssl` cookie flags + secure_headers gem: the header matrix, `no-store`, production cookie hardening | | `kernel/rate_limit.rs` | `guides/security.md` | Rails 8 `rate_limit` | | `kernel/mailer.rs` | `guides/mailers.md` | Action Mailer delivery (`Smtp`/`Local`/`Log`/`Memory`) | | `kernel/storage.rs` | `guides/storage.md` | Active Storage service | | `kernel/assets.rs` | `guides/frontend-*.md` | Propshaft digests / Vite manifest; in development (Vite modes) also serves `/dev/vite-preamble.js` ([frontend-inertia](guides/frontend-inertia.md)) | | `kernel/inertia.rs` | `guides/frontend-inertia.md` | inertia-rails | ## Banned (don't add these, don't propose them) Enforced mechanically: `deny.toml` `[bans]` fails CI if any of these appears anywhere in the dependency tree. Crates that healthy dependencies pull in transitively (`lazy_static`, `once_cell`, `async-trait`, `log`) are banned as **direct** dependencies only, which the plugin hook and `/offrails:doctor` check ([security](guides/security.md#automated-enforcement)). | Banned | Why | |------------------------------------------|-----| | Diesel, raw `sqlx` queries in app code | SeaORM is the one data layer. Raw SQL breaks portability (kernel excepted) | | Actix, Rocket, Poem, Loco, Leptos, Dioxus, Yew | One web stack. No WASM front ends | | Tera, Handlebars, minijinja | Runtime templates hide errors until a request hits them | | `askama_axum`, `axum-inertia` and other niche glue crates | Removed upstream or thin. Generate the glue from the spec | | `lazy_static`, `once_cell` | Use `std::sync::LazyLock` / `OnceLock` | | `async-trait` (as your own dependency) | Native async fn in traits is stable. Exception: `sea-orm-migration`'s `MigrationTrait` requires it, so use the re-export that crate provides | | Node/Vite in `htmx` mode (except islands) | Vendored htmx + Tailwind standalone. One sanctioned exception: the dev-only real-browser smoke package in `tests/e2e/` (`@playwright/test` only), which never touches the build, image or runtime ([test-standards §9](guides/test-standards.md#9-real-browser-smoke)) | | Next.js, Remix/RR framework mode, SvelteKit SSR/server routes/form actions, any SSR JS server | Rust is the only server | | npm, yarn, webpack, CRA, Jest, ESLint+Prettier | pnpm, Vite, Vitest, Biome | | Redux, MobX, Zustand (by default) | Server state is props (`inertia`) or TanStack Query (`spa`); UI state lives in `useState`/context | | axios, ky | Inertia's router or `fetch` through `api/client.ts` | | JWT / localStorage tokens | Same-origin cookie sessions in every mode | | CSS-in-JS, component kits (MUI, Chakra) | Tailwind. Generate components into `web/src/components/` (shadcn-style: generated, owned) | | Hand-written TS types for server data | Always generated by `ts-rs` | | Redis, RabbitMQ, Kafka, Elasticsearch | The database does jobs, cache and pub/sub | | `config`, `figment` | Env vars into one struct | | `log` crate macros | Use `tracing` | | `reqwest` with `native-tls` | If you need HTTP egress, use `reqwest` with `rustls-tls` and no default features | ## Adding a crate not on this list Only when a `product/` spec requires a capability nothing above provides (for example, PDF generation or Stripe). Then: 1. Pick the most-downloaded, most-boring option. 2. `cargo add` it. 3. Add a line to the app's `docs/decisions.md`: `YYYY-MM-DD: added for , because `. ================================================================ FILE: workflow.md (https://rustoffrails.org/docs/workflow.md) ================================================================ # Agent Workflow ## The human interface: `product/` Humans describe the app in `product/README.md` and `product/features/*.md` (template: `blueprints/feature-spec.md`). They can also just talk to you. When they do, **write or update the spec file first**, then build. The spec is the durable record. Chat is not. Before building anything new, run the relevant part of [`interview.md`](interview.md). Ask what only the human can answer, assume everything else, and write it all down. ## The loop ``` 0. ASK interview.md: new app → full interview; new feature → feature-level checklist 1. READ spec → resources, fields, rules, side effects, acceptance criteria 2. PLAN where each piece starts: a snippet (cargo offrails snippets; snippet show ), a generator (cargo offrails g --help; --dry-run lists its files), or the guides; a kernel need → a part (cargo offrails parts); show the plan for new apps/big features 3. START cargo offrails snippet add … / cargo offrails g resource|model|api|… (files + marked wiring + tests), or write the files by hand in the same shape (small files per feature, one marked line per shared spot); commit right after a generator or snippet run; `cargo run -- db migrate`; prove rollback 4. MODEL customize the `// APP:` lines: validation rules, scopes the spec names, side effects (inertia/spa: derive TS on Params + view props; tests export them) 5. TEST failing tests for the acceptance criteria, next to the generated ones 6. BUILD controller, routes, views (templates from the component kit, components/ui.html, in a layout / Inertia pages / SPA routes), jobs, mailers 7. CHECK cargo check → fix → repeat (the compiler is your pair); one test: cargo nextest run running app: bin/dev rebuilds, restarts and reloads open tabs on save; ask it before guessing: curl -s $URL/dev/introspect.json (migrations, css, realtime, url) 8. VERIFY cargo offrails verify after each slice (seconds); --full once, before you say done; pages: BASE=… cargo offrails prove → docs/proof/PROOF.md, linked in your final message 9. RECORD docs/routes.md, docs/decisions.md, spec checkboxes ``` Work in **vertical slices**: one resource or behavior end-to-end, green, then the next. Never leave the tree not compiling between slices. ### Parallel helpers (subagents) One Rust crate is one compile unit: an error in any file blocks `cargo check` for every file. So parallel helpers only pay off when the tree already compiles **and** each helper owns separate files. - **Never run parallel helpers before the skeleton compiles.** Generate it from the template yourself (`generators/new.md`), run one `cargo check`, then make the baseline commit. - **After that, at most one helper per vertical slice.** Each helper's prompt lists the exact files it owns and says "touch nothing else". Shared files (`src/routes.rs`, `src/kernel/mod.rs`, `src/models/mod.rs`, `migration/src/lib.rs`, `Cargo.toml`) are edited by you, the coordinator, after the helpers report. - **A helper whose `cargo check` fails because of another helper's file stops and reports.** It doesn't edit that file, stub it out, or build a scratch crate in `/tmp` to type-check its own part. Those workarounds hide integration errors and burn tokens. - Read-only helpers are always fine: exploring a reference app, mapping routes, drafting specs in `product/`. ## When the product grows a need, add the part Before you hand-write kernel code (a mailer, a job queue, sessions and sign-in, a socket layer, a dashboard), check whether a part does it: `cargo offrails list-parts`. If one does, add it, and build the feature on top: ```sh cargo offrails add-part mailer # e.g. the spec now says "email the owner when…"; dependencies come first cargo run -q -- db migrate && cargo run -q -- routes ``` Typical moments: the first email (`mailer`, `dev-mailbox`), the first slow, retried or scheduled work (`jobs`, `jobs-dashboard`), the first sign-in (`auth-core` + a method), the first push to other users (`realtime`). The [decision table](template/README.md#choosing-parts) maps spec signals to parts. Tell the human in one line what the part adds (link: , screenshots of every screen), log it (`add-part` writes the `docs/decisions.md` line), then the Definition of Done on both databases. Hand-write it only when no part fits, or the human declined the part (recorded in `docs/decisions.md`). ## Definition of Done All must pass. Gates and thresholds are defined in [`guides/test-standards.md`](guides/test-standards.md). One command runs them, in parallel where they are independent: ```sh cargo offrails verify # --fast, after every slice: fmt --check, the CSS (htmx), clippy -D warnings, # one test build, then the SQLite and Postgres suites at the same time cargo offrails verify --full # once, before you say done: also offrails-check, coverage (the SQLite run, instrumented), # cargo deny, gitleaks, the no-unwrap review, routes vs docs/routes.md, the TS checks # (web/), and the browser smokes when BASE= is set (the kernel # pages; every page after a boosted link: "skip: no Chrome" without a browser), brand --check, # and `prove` (screenshots + checks of every page → docs/proof/PROOF.md) ``` It prints one line per step with its time and test counts, the tail of each failure (full logs in `target/verify/`), and exits non-zero on any failure. Postgres comes from `TEST_DATABASE_URL` (the environment or `.env`), else the compose `db` URL when something listens on `localhost:${DB_PORT:-5432}`; with neither, the Postgres run is reported **skipped**, never passed. `cargo fmt --all` fixes what `fmt --check` reports. **Iterate fast, verify fully once.** While a slice is red, run only what you're fixing: `cargo check`, then `cargo nextest run `. At the end of each slice, `cargo offrails verify` (measured after a one-line change, M2, load ~10: a bare app 5.5–6.7 s, an app with every part 33–37 s, most of it the Postgres suite). Never run coverage, `cargo deny`, gitleaks or the full browser smokes per iteration: they belong to `--full`, once, when the work is done. A real-browser smoke of the flow you changed is still due for UI changes (the table below), against the app `bin/dev` is already serving. What `verify` runs, for running one step by hand: ```sh cargo fmt --all --check bin/tailwindcss -i assets/app.css -o static/app.css --minify # htmx mode CARGO_TARGET_DIR=target/clippy cargo clippy --all-targets -- -D warnings # its own dir, so it compiles beside the tests cargo nextest run # SQLite; also re-exports ts-rs types TEST_DATABASE_URL=postgres://postgres:postgres@localhost:${DB_PORT:-5432}/postgres cargo nextest run -E 'kind(test)' # Postgres: required locally when reachable; CI always runs it # --full adds: sh .offrails/tools/offrails-check # header, every AC cited, Security-table tests exist, tenancy suites, no unscoped lookups (exit 0) cargo llvm-cov nextest --summary-only --ignore-filename-regex 'src/main\.rs$' --fail-under-lines 80 # test-standards.md §5: ≥ 80% lines overall, ≥ 90% for src/models and src/policies cargo deny check # advisories, licenses, banned crates, sources (guides/security.md#automated-enforcement) gitleaks git . --redact --no-banner # secrets anywhere in history (.gitleaks.toml) pnpm --dir web exec biome check . && pnpm --dir web typecheck && pnpm --dir web test --run && pnpm --dir web build && pnpm --dir web audit --prod # TS modes; React: `tsc -b` (never `tsc --noEmit`); Svelte: svelte-check ``` **Postgres on another port:** if 5432 is taken (another app's Postgres, a native install), never stop it: run the compose `db` service elsewhere with `DB_PORT=54329 docker compose up -d db` and test with `DB_PORT=54329` (or set `TEST_DATABASE_URL` to any Postgres you may create databases in; the suite makes a template and one reusable slot per concurrent test). Check first with `lsof -nP -iTCP:5432 -sTCP:LISTEN`. Plus, each with the command that proves it (paste its output, or say which item you skipped and why): | Check | Command (prints nothing / exits 0 when it passes) | |-------|---------------------------------------------------| | Accessibility, apps with a UI: zero violations on every route, light **and** dark, desktop and mobile ([test-standards §10](guides/test-standards.md#10-accessibility-wcag-22-aa-in-every-theme)) | `pnpm --dir web exec playwright test a11y` (TS modes) · `pnpm --dir tests/e2e exec playwright test a11y` (htmx), against a running app | | Every `AC--` has a test citing it; ticked only when that test passes on both databases | `sh .offrails/tools/offrails-check` (the `[ac]` lines) | | New migrations roll back cleanly | `cargo run -- db rollback --steps && cargo run -- db migrate` | | No `unwrap()`/`expect()` outside tests and startup | `cargo clippy -q --lib --bins -- -W clippy::unwrap_used -W clippy::expect_used` (non-test code only); every hit is startup code with a message saying why it can't fail | | No raw SQL outside `src/kernel/` and logged escape hatches | `grep -rn -e 'Statement::from_string' -e 'execute_unprepared' src/ \| grep -v '^src/kernel/'` | | (inertia/spa) `web/src/types/` untouched by hand; no TS type duplicates a Rust type | `cargo test --lib export_bindings && git diff --exit-code web/src/types/` | | No `todo!()`, `unimplemented!()`, or `// TODO` without a linked spec "Open question" | `grep -rn -e 'todo!(' -e 'unimplemented!(' -e '// TODO' src/ web/src/ 2>/dev/null` | | New routes are in `docs/routes.md` | `cargo run -- routes && git diff --exit-code docs/routes.md` | | New env vars are in `.env.example` (commented out is fine), `Config` and the deploy config | `sh .offrails/tools/offrails-check` (`[env]`: every `"SCREAMING_SNAKE"` literal in `src/config.rs`, however it's read, must be in `.env.example`); the deploy config by hand | | Decisions not covered by `.offrails/` are logged | `git diff --stat docs/decisions.md` shows a line for each new choice ([conventions](#decisions)) | | **Security-relevant changes** (auth, sessions, permissions, tenancy, payments, uploads, realtime, APIs, webhooks, outbound fetches, anything new in `headers_test.rs`) | `/offrails:security` or its checklist; the spec's `## Security` STRIDE table names an **attack test** per threat (failing first, then fixed), and `offrails-check` finds each one. A Non-negotiable ([security](guides/security.md#non-negotiables)) is never weakened without a decision **and** the human's explicit OK. ZAP runs in CI; run it locally when you add routes or change headers | | User-visible changes: you ran the app and exercised it. **UI changes: a real-browser smoke** of the changed flow ([test-standards §9](guides/test-standards.md#9-real-browser-smoke)) | `BASE= bash .offrails/tools/browser-smoke/run.sh` (styling, Back/Forward, no console errors or CSP violations; Node ≥ 22.12 and Chrome, else it exits 77 and `verify` reports a skip); every page after a boosted link: `BASE=… node .offrails/tools/browser-smoke/smoke-boosted.mjs` ([testing](guides/testing.md#browser-smoke-boosted-navigation); both run in `verify --full` with `BASE` set; with password sign-in it also signs in, as `demo@example.com` from `cargo run -- task auth_demo`, or as `SMOKE_EMAIL`/`SMOKE_PASSWORD` when an app's own seed account differs: `SMOKE_EMAIL=me@example.com SMOKE_PASSWORD=… BASE=… cargo offrails verify --full`; pages with parameters: `SMOKE_PATHS=/posts/1,/blog/hello`); accounts: `SCENARIO=tests/e2e/smoke.scenario.mjs … run.sh login` (no certificate overrides: the session must survive a fresh page load); `… run.sh a11y` (axe, light + dark); live pages: `… run.sh live-tabs`. `curl -s $BASE/dev/introspect.json \| jq .css.stale,.migrations.current` prints `false`, `true`. Emails: `curl -s $BASE/dev/mailbox/latest.json`. Jobs: `cargo run -- jobs list --state discarded` is empty | | Templates correct in both color schemes; the built CSS has every class (htmx) | `cargo nextest run theme_test` ([test-standards §11](guides/test-standards.md#11-theme-and-css-guards)) | | **Proof** (apps with pages): every page screenshotted and checked, each acceptance criterion traced to its passing tests and pages. **Link `docs/proof/PROOF.md` in your final message** | `BASE= cargo offrails prove` ([testing](guides/testing.md#proof-cargo-offrails-prove); `verify --full` runs it with `BASE` set) | ## When something fails - **Compile error in a crate API:** check the version in `Cargo.lock`, read that version's docs, and fix the call. Don't swap crates. - **"no such table" / "relation does not exist" at runtime:** run `cargo run -- db migrate`. - **Passes on SQLite, fails on Postgres (or the reverse):** you broke a portability rule in `guides/database.md`. Fix the code, not the test. - **The same error three times:** stop, reread the relevant guide, and reduce the change to the smallest failing piece. - **Evidence first, workarounds last.** Before you add a retry, a timeout, a watchdog, a reload cap, a `sleep`, a polling fallback or a raised limit, capture the evidence of the actual failure: the request and its status (`curl -skv`, the browser's network panel, `chrome://net-export`), the server log line with its `request_id`, the protocol (`%{http_version}`). Then fix the cause. A workaround that stays needs a `docs/decisions.md` line citing that evidence and the cause it covers for. Without one, the next agent deletes it. (A 0.3 app shipped a stall watchdog and reload caps around what was HTTP/1.1 connection starvation; [realtime](guides/realtime.md#many-tabs-stall-http11-vs-http2).) - **Genuinely blocked** (needs a credential, an external account, or a product decision): stop and tell the human exactly what you need and why. Don't stub around it silently. ## Decisions `docs/decisions.md` is the memory between agents. One dated line per choice `.offrails/` didn't dictate, newest last: ``` 2026-10-13: board cards load lazily past 50 (because boards with 400 cards took 2 s; spec: product/features/boards.md) ``` - **Superseding:** never edit or delete an old line. Add a new one that names it: `2026-11-02: supersedes 2026-10-13 (lazy cards): HTTP/2 fixed the stall, cards load eagerly again`. A reader takes the newest line on a topic. - **Workarounds** (retries, watchdogs, caps, fallbacks, raised limits) cite the evidence and the cause they cover for ([evidence first](#when-something-fails)). When the cause is fixed, the line is superseded and the workaround removed in the same change. - **Upgrades** reconcile this file: a decision that a new framework release makes obsolete gets a superseding line ([upgrade](generators/upgrade.md)). ## Human code edits The rule is zero handwritten code, but humans will occasionally paste a fix. When you notice one (via `git log`/`git blame` or because they say so): 1. Don't revert it. 2. Make it conform to these conventions if it doesn't. 3. Capture the *intent* in the relevant spec or `docs/decisions.md`, so a future regeneration keeps it. ## Commits One commit per vertical slice. Message format: `: ` plus a body listing the spec file(s) implemented. Commit only when the human has asked you to manage commits. The one exception is the baseline commit at the end of `generators/new.md`. **After a generator or snippet run, commit before you edit** (when commits are yours to make): `git add -A && git commit -m "generate resource posts"`, then customize. The generated code and your changes are separate diffs: review sees what you decided, a bad edit reverts without losing the scaffold, and `destroy` still recognizes the files you left alone. When the human makes the commits, say which files a generator wrote in your report. ## Talking to the human Report in product terms ("Books can now be reviewed; owners get an email"), then link the proof (`docs/proof/PROOF.md`: what each criterion's tests and screenshots show), then list files changed, then any assumptions made. Never tell the human to edit code. Tell them which spec to edit, or just to tell you. ================================================================ FILE: cli.md (https://rustoffrails.org/docs/cli.md) ================================================================ # CLI: the `app` binary is `bin/rails` Every app compiles to one binary with `clap` subcommands. The binary is named after the crate. These docs call it `app`. ## Subcommands to generate in `src/main.rs` ``` app serve # rails server (default when no subcommand) app worker [--queues default:10,mailers:5] # bin/jobs (queue producers + Stager/Lifeline/Pruner/Cron) app db migrate # rails db:migrate (embedded Migrator::up) app db rollback [--steps N] # rails db:rollback (Migrator::down) app db status # rails db:migrate:status (Migrator::get_migration_with_status; print `{:?}` of `m.status()`: "Applied/Pending ". MigrationStatus isn't in the prelude, and Debug needs no import) app db seed # rails db:seed (src/tasks/seed.rs; one transaction, find-or-create per record: #seeds) app db setup # rails db:setup (migrate + seed) app db reset # Migrator::fresh (drop all tables, migrate) + seed; refuses unless APP_ENV=development app db console # rails dbconsole (runs sqlite3 or psql , based on DATABASE_URL; no pool opened) app routes # rails routes (prints ROUTES with access levels, rewrites docs/routes.md; no DB needed) app task [args…] # rails runner / rake tasks (src/tasks/.rs) app jobs stats|list|show|retry|cancel|delete|run|pause|resume|prune # Mission Control / Oban Web, in a terminal app health # container healthcheck: GET /up on 127.0.0.1:$PORT, exit 0 on 200 (no DB, no curl in the image) app introspect # development only: the app's state as JSON (routes, redacted config, migrations, jobs, assets, CSS staleness); live streams/subscribers: GET /dev/introspect.json (guides/dev-tools.md#introspection) app task tokens create [name] | list | revoke # API tokens (apps with an API; guides/api.md) ``` App jobs, workers and `app jobs …` exist once the `jobs` part is in; API tokens once accounts and an API are. **The framework's generators**, from the app root on any OS (the alias in `.cargo/config.toml`; [template](template/README.md#using-it)): ``` cargo offrails generate [field:type…] [--dry-run] [--force] # alias g; `g --help` lists the kinds cargo offrails snippets # small tested code to copy (feed, slugs, Markdown, pager…) cargo offrails snippet show # its README, files and wiring cargo offrails snippet add [--as ] # copied and wired, recorded; `snippet remove ` takes it back cargo offrails destroy [--dry-run] # alias d; removes only unchanged files cargo offrails parts # parts: installed or available, what each needs and adds (alias list-parts) cargo offrails add … [--dry-run] # add parts (dependencies first); re-running is a no-op (alias add-part) cargo offrails update-part … --framework [--dry-run] # 3-way merge of a newer part, conflicts marked cargo offrails verify [--fast|--full] # the Definition of Done in parallel cargo offrails brand [--check] # brand.toml → theme CSS, src/brand.rs, favicon set, social card (WCAG AA checked) BASE=… cargo offrails prove [--strict] # screenshots + checks of every page → docs/proof/PROOF.md (the app must be running) ``` Verified (reference app): `Migrator::up(&db, None)`, `Migrator::down(&db, Some(steps))`, `Migrator::fresh(&db)` and `Migrator::get_migration_with_status(&db)` from `sea-orm-migration` 2.0.4. `main` parses the CLI, loads `Config::from_env()`, initializes tracing, then connects. `routes` and `db console` run before connecting. `serve` binds `$APP_BIND:$PORT`: **`127.0.0.1` in development** (its `/dev` tools have no login), `0.0.0.0` in production (the container's interface, behind the platform's proxy); a dev container sets `APP_BIND=0.0.0.0` ([security](guides/security.md#development-is-this-machine-only)). It serves `into_make_service_with_connect_info::()` (rate limiting needs the client IP), and shuts down gracefully on Ctrl-C/SIGTERM. It also runs the worker in-process when `APP_INLINE_WORKER=true`, like `SOLID_QUEUE_IN_PUMA`. That's the default in development, and in production on SQLite, so a single process does everything. Every subcommand reads `DATABASE_URL`, so **any command works against any database**: ```sh cargo run -- db migrate # local SQLite DATABASE_URL=postgres://… cargo run -- db status # a remote Postgres, from your laptop ``` ## Migrations on deploy The binary embeds the `migration` crate. The Docker entrypoint (`bin/docker-entrypoint`, generated) runs `app db migrate` and then `exec`s the given command, like Rails 8's `db:prepare` on boot. Postgres migrations take an advisory lock, so concurrent boots are safe. On SQLite there is one server by definition. Platform-specific "release command" alternatives are in `guides/deployment.md`. ## Command cheat sheet | Rails | Off Rails | |-------------------------------|-----------| | `rails new` | `interview.md`, then `generators/new.md` | | `bin/setup` | `bin/setup` (installs tools, creates `.env` from `.env.example`, `db setup`) | | `bin/dev` | `bin/dev`: `cargo watch` rebuild + restart, Tailwind `--watch` (`htmx`) or Vite (`inertia`/`spa`), and open tabs reload themselves (`static/dev-reload.js` polls `/dev/reload`) ([hot reload](guides/dev-tools.md#hot-reload)). HTTPS only with a trusted certificate (mkcert), else plain http; it prints the URL to open ([HTTPS in development](guides/dev-tools.md#https-in-development)) | | `rails s` | `cargo run -- serve` | | `rails c` | No REPL. Write `src/tasks/.rs` and run `cargo run -- task ` | | `rails dbconsole` | `cargo run -- db console` | | `rails db:migrate` | `cargo run -- db migrate` | | `rails db:rollback` | `cargo run -- db rollback` | | `rails db:migrate:status` | `cargo run -- db status` | | `rails g migration X` | `cargo offrails g migration _ [field:type…]` (never `sea-orm-cli migrate generate`: it drops the anchors) | | `rails g …` / `rails d …` | `cargo offrails g …` / `cargo offrails d ` ([generators](generators/README.md)) | | `rails routes` | `cargo run -- routes` (or `/dev/routes` in the browser) | | Mission Control – Jobs | `/admin/jobs`, or `cargo run -- jobs stats` / `jobs list --state discarded` ([jobs-dashboard](guides/jobs-dashboard.md)) | | letter_opener_web | `/dev/mailbox` ([mailers](guides/mailers.md)) | | (none: ask the running app) | `curl -s $URL/dev/introspect.json` or `cargo run -q -- introspect` ([dev-tools](guides/dev-tools.md#introspection)) | | `rails test` | `cargo nextest run` (SQLite) / `TEST_DATABASE_URL=postgres://… cargo nextest run` | | `rubocop` | `cargo fmt --check && cargo clippy --all-targets -- -D warnings` | | (none: specs ↔ code gates) | `sh .offrails/tools/offrails-check` or `offrails check`: header consistency, every AC cited, Security-table tests exist, tenancy suites, no unscoped lookups, `.env.example` complete, 64-bit ids, tests that can run on Postgres, development bound to this machine with a Host check. Exit 1 on any `FAIL`; runs in CI ([workflow](workflow.md#definition-of-done)) | | `brakeman` | `guides/security.md` checklist (+ optional `cargo audit`) | | `rails assets:precompile` | `htmx`: `bin/tailwindcss -i assets/app.css -o static/app.css --minify`. `inertia`/`spa`: `pnpm --dir web build` | | `yarn test` | `pnpm --dir web test --run` (Vitest) | | `kamal deploy` / `fly deploy` | see `guides/deployment.md` | ## Running the app from an agent - **Start it as a tracked background task** using your harness's mechanism (Claude Code: Bash with `run_in_background`), running `bin/dev` or `cargo run -- serve`, **with a PID file** (below). Don't use `nohup … &` from a tool call when you are the session the human talks to: the harness can reap it within minutes, and the human then gets "connection refused". - **A sub-agent that hands a running server back to its caller** is the exception: its tracked background tasks end when it does, so the server dies with it. Start it detached, with its output in a file and its PID file, and hand back the three facts the caller needs: ```sh mkdir -p storage/pids storage/logs PORT=3005 nohup bin/dev > storage/logs/dev-3005.log 2>&1 < /dev/null & # bin/dev writes storage/pids/dev-3005.pid # wait for ✓ ready in the log, then prove it: curl -fsS /up ``` Report: the URL `bin/dev` printed, `storage/pids/dev-3005.pid`, `storage/logs/dev-3005.log`. The caller (or the human) stops it with `kill "$(cat storage/pids/dev-3005.pid)"`. Re-check `/up` from the caller before telling the human it's up: if it's gone, start it again as the caller's own background task. - **Pick a free port** and set `PORT`. Check it first with `lsof -nP -iTCP:$PORT -sTCP:LISTEN`: on macOS, development's `127.0.0.1:$PORT` bind succeeds even when another process holds `*:$PORT` (a container's published port, a production-mode run, another framework's dev server), because tokio sets `SO_REUSEADDR`. Then `127.0.0.1` reaches your app while `localhost` over IPv6 (`[::1]`) may silently reach the *other* one (verified 2026-10-16: with `*:P` or `[::]:P` held, a `127.0.0.1:P` bind succeeds; with `127.0.0.1:P` held, it fails). A taken `127.0.0.1:$PORT` still fails the bind with a clear error. After starting, confirm `/up` answers from **your** process (the PID from `lsof`). In development, `APP_URL` defaults to `https://localhost:{PORT}` when `serve` has TLS (a trusted certificate from `bin/dev-cert`) and `http://localhost:{PORT}` otherwise ([config](guides/config.md)), so email links and the CSRF origin check follow the port and scheme. **Use the exact URL `bin/dev` printed** (`→ …` and `✓ ready: …`); a bare `localhost:PORT` is http to a browser (the TLS port answers it with a 307 to https). - **Prove it's up before telling the human:** `curl -fsS $URL/up` with the printed URL (`-k` only with `DEV_CERT_SELF_SIGNED=1`, and then tell the human to open the printed http URL, never the self-signed one). Re-check after any restart. Then `curl -s $URL/dev/introspect.json` answers what state it's in (pending migrations, stale CSS, protocol). - **Never tell a human to click through a certificate warning.** Chrome drops cookies on an untrusted certificate, so login bounces ([dev-tools](guides/dev-tools.md#https-in-development)). - **One PID file per server, in `storage/pids/`.** This is how any agent (Claude Code, opencode, Codex, a script) stops exactly the server it started, and nothing else. The plugin's hook that blocks `pkill` only exists in Claude Code; this convention works everywhere. A peer's agent ran `pkill -f` on a server name and killed the human's own demo. | Started with | PID file | Stop it | |---|---|---| | `PORT=3005 bin/dev` | `bin/dev` writes `storage/pids/dev-3005.pid` itself, refuses to start if that PID is alive or the port is taken, and removes the file on exit | `kill "$(cat storage/pids/dev-3005.pid)"`: bin/dev stops its whole tree (cargo watch, the server, Tailwind, Vite) and nothing else | | `cargo run -- serve` | `mkdir -p storage/pids && PORT=3005 sh -c 'echo $$ > storage/pids/serve-3005.pid; exec cargo run -q -- serve'` (`cargo run` execs the app, so the PID is the server's; verified) | `kill "$(cat storage/pids/serve-3005.pid)" && rm storage/pids/serve-3005.pid` | Then confirm: `lsof -nP -iTCP:3005 -sTCP:LISTEN` prints nothing. **Never** `pkill`/`killall`, and never kill by name or pattern (`pkill -f`, `ps | grep | xargs kill`). **For `bin/dev`, never kill only the listening PID** (`lsof -ti … | xargs kill`): the server dies, but cargo watch and Tailwind keep running and start it again on the next edit. Killing the listener is right only for a plain `serve` you started without a PID file, after checking with `lsof -nP -iTCP:$PORT -sTCP:LISTEN` that the PID is yours. - **Non-interactive runs (`claude -p`, `opencode run`, `codex exec`, CI): stop every server you started before your final message.** A background `bin/dev` (or `cargo watch`, Vite, Tailwind watch) keeps the session's process tree alive, so the run never exits. Last step: `kill "$(cat storage/pids/)"` for each PID file you created, then confirm with `lsof -nP -iTCP:$PORT -sTCP:LISTEN` printing nothing for each port you used (Vite's too). In an interactive session, leave it running only if the human will use it, and say on which URL. - **Read emails through the mailbox JSON** (`/dev/mailbox/latest.json?to=…`), never by grepping raw `.eml` files ([mailers](guides/mailers.md)). ## Seeds `src/tasks/seed.rs` runs in **one transaction** (`kernel::db::begin`, committed at the end) and is idempotent **per record**: each record is found by a natural key (email, slug, team name) and created only if missing. Never key the whole seed on one check (`if teams::count(db) > 0 { return }`): a seed that failed after the first team then never retries, and the dev database stays half-seeded for good (a peer's `seed_if_empty` did exactly that). Running `app db seed` twice changes nothing; a failed run leaves nothing behind. Development only for accounts with known passwords: refuse in production. ## Tasks (`src/tasks/`) Each task is `pub async fn run(state: &AppState, args: Vec) -> anyhow::Result<()>`, registered in a `match` in `src/tasks/mod.rs`. Tasks are where data fixes, backfills and admin operations go. They're also how you "use the console": write what you'd have typed in `rails c` as a task, run it, and delete it if it was throwaway. Data-fix tasks get committed so there's a record. ================================================================ FILE: RAILS_MAP.md (https://rustoffrails.org/docs/rails-map.md) ================================================================ # Rails → Off Rails Map When a human (or your training data) says "the Rails way", look it up here. ## Frameworks | Rails | Off Rails | Guide | |---------------------------|--------------------------------------------------------|-------| | Active Record | SeaORM entities + query fns in `src/models/` | [models](guides/models.md) | | `database.yml` / adapters | `DATABASE_URL`: SQLite or Postgres, same code | [database](guides/database.md) | | Active Record Migrations | `migration/` crate (sea-orm-migration DSL), embedded in the binary | [migrations](guides/migrations.md) | | Active Model validations | `Params::validate() -> Result, Errors>` | [models](guides/models.md#validations) | | Action Controller | axum handlers in `src/controllers/` | [controllers](guides/controllers.md) | | Routing (`routes.rb`) | `src/routes.rs` (one file) | [routing](guides/routing.md) | | Action View / ERB | Askama templates + view structs | [views](guides/views.md) | | Hotwire (Turbo + Stimulus)| `htmx` mode: htmx 2.x + `htmx-ext-sse` (+ `static/app.js` for small behavior) | [frontend-htmx](guides/frontend-htmx.md) | | Propshaft / importmap | `static/` + `kernel/assets.rs` digests, vendored htmx | [frontend-htmx](guides/frontend-htmx.md) | | tailwindcss-rails | Tailwind v4 standalone CLI pinned in `bin/setup-tailwind` (`htmx`, islands) / `@tailwindcss/vite` (`inertia`, `spa`) | [frontend-htmx](guides/frontend-htmx.md) | | `rails new --api` | `FRONTEND_MODE: api`: no UI, REST `/api/v1` and/or GraphQL, bearer tokens | [api](guides/api.md) | | graphql-ruby (+ graphiql-rails, graphql-batch) | async-graphql: `src/graphql/`, `/dev/graphiql`, a DataLoader per association | [graphql](guides/graphql.md) | | rswag / OpenAPI docs | utoipa annotations → `/api/openapi.json`, committed `docs/openapi.json`, Scalar at `/dev/api-docs` | [api](guides/api.md#openapi-api-rest--both) | | `has_secure_token` API tokens / Doorkeeper | `api_tokens` (SHA-256 digests, scopes, expiry), `ApiUser` extractor | [api](guides/api.md#authentication-api-tokens) | | rack-cors | tower-http `CorsLayer` from `APP_CORS_ORIGINS` (exact origins) | [api](guides/api.md#cors) | | Puma | The binary itself: axum on hyper (HTTP/1.1 + h2); optional direct TLS via `APP_TLS_*` | [deployment](guides/deployment.md#direct-tls-optional) | | `docker compose up` (Rails 7.1+ devcontainer) | `compose.yaml` from `new`: the production image + Postgres, profiles `worker`, `mail` | [deployment](guides/deployment.md#docker-compose-the-whole-app-locally) | | `favicon.ico`, meta-tags gem | `brand.toml` + `cargo offrails brand` + `Ctx` meta (title, description, canonical, OG, Twitter) from `new` | [views](guides/views.md#brand) | | ViewComponent, a UI kit, `layout "admin"` | `{% import "components/ui.html" as ui %}` macros; `{% extends "layouts/sidebar.html" %}` (application, sidebar, marketing, auth, docs) | [views](guides/views.md#components) | | Capybara screenshots, a Percy-style visual review | `BASE=… cargo offrails prove` → `docs/proof/PROOF.md` | [testing](guides/testing.md#proof-cargo-offrails-prove) | | inertia-rails | `inertia` mode: generated `kernel/inertia.rs` (Inertia v3 protocol) + `@inertiajs/react` | [frontend-inertia](guides/frontend-inertia.md) | | jsbundling / vite_rails | Vite 8 in `web/` (`inertia`, `spa`, islands), manifest read by `kernel/assets.rs` | [frontend-inertia](guides/frontend-inertia.md) | | Jbuilder / serializers | `#[derive(Serialize, TS)]` structs in `src/views/` | [frontend-react](guides/frontend-react.md) | | Active Job + Solid Queue | `kernel/jobs/`: Oban-style DB-backed queue, `app worker` | [jobs](guides/jobs.md) | | Mission Control – Jobs | `/admin/jobs` dashboard + `app jobs …` | [jobs-dashboard](guides/jobs-dashboard.md) | | Action Mailer | `lettre` + Askama, delivered via jobs | [mailers](guides/mailers.md) | | letter_opener(_web), mailer previews | `/dev/mailbox` (`Mailer::Local`), with mailer previews in its Previews tab | [mailers](guides/mailers.md), [dev-tools](guides/dev-tools.md) | | Action Mailbox | Not included. Webhook controller if a spec needs it | | | Action Cable + Solid Cable| SSE + `broadcasts` table (`kernel/pubsub.rs`) | [realtime](guides/realtime.md) | | Action Cable channels (two-way) | One WebSocket, topics, room actors (`kernel/hub.rs`, `kernel/channels.rs`) | [channels](guides/channels.md) | | Active Storage | `object_store` + `attachments` table | [storage](guides/storage.md) | | Action Text | Markdown column → `pulldown-cmark` → `ammonia` | [views](guides/views.md#rich-text) | | Solid Cache / `Rails.cache` | `kernel/cache.rs`: `moka` L1 + `cache_entries` table | [caching](guides/caching.md) | | Active Support | std + `chrono`. No monkey patching exists to port | | | `rails generate authentication` | [generators/authentication.md](generators/authentication.md) | [auth](guides/auth.md) | | Credentials (`credentials.yml.enc`) | Env vars; secrets from the deploy platform | [config](guides/config.md) | | Environments (dev/test/prod) | `APP_ENV` + `Config` | [config](guides/config.md) | | Kamal | Kamal 2, Fly.io, Render or Railway: the same Docker image everywhere | [deployment](guides/deployment.md) | | Rails console | No REPL. Write an `app task`, or use `psql` via `app db console` | [cli](cli.md) | | Minitest / system tests | `TestApp` integration tests via `oneshot`, on SQLite + Postgres | [testing](guides/testing.md) | | Fixtures / FactoryBot | `tests/app/factories.rs` | [testing](guides/testing.md) | | `rails new` (+ its flags) | [interview.md](interview.md) → [generators/new.md](generators/new.md) | | | `rails generate …` | `cargo offrails generate …` (`g`): migration, model, scaffold (`resource`), controller, page, api, job, mailer, channel, test; optional, like a snippet | [generators](generators/README.md) | | a Rails guide's code sample, copied | `cargo offrails snippets`, `snippet show `, `snippet add [--as ]` (feed, slugs, Markdown, pager, theme toggle, backdated seeds, public page by slug) | [snippets](template/snippets/README.md) | | `rails destroy …` | `cargo offrails destroy ` (`d`): only files nobody changed | [generators](generators/README.md) | | Engines / gems that add features | parts: `cargo offrails parts`, `add `, `update-part ` | [template](template/README.md#choosing-parts) | | `bin/rails` commands | the `app` binary + cargo | [cli](cli.md) | | Rack middleware | `tower` layers in `routes.rs` | [routing](guides/routing.md#middleware) | | I18n | English-only by default. If a spec requires locales, add `rust-i18n` and log the decision | | ## Active Record concepts | Rails | Off Rails | |------------------------------------|-----------| | `Post.find(id)` | `post::find(&db, id).await?` → `AppError::NotFound` on miss | | `Post.where(...)`, scopes | Named query fns: `post::published(&db, page)`, `post::by_author(&db, id)` | | `post.save` | `post::create(&db, author_id, new)` / `post::update(&db, post, new)` | | `has_many :comments` | `Relation::Comments` + `comment::for_post(&db, post_id)` | | `belongs_to :author` | `author_id: i64` + FK + `Relation::Author`; `post.find_related(user::Entity)` | | `has_many :through` | Join entity + `Related` impl with `via()` | | `includes(:comments)` (N+1 fix) | `find_also_related` (1:1) or `LoaderTrait::load_many` (1:N) | | Validations | `Params::validate()`, plus DB constraints as the backstop | | `validates :email, uniqueness:` | `UNIQUE` index + map the unique violation to a field error | | Callbacks (`after_create`) | **Banned.** Call the side effect explicitly in the model fn | | Concerns | Traits only when ≥3 models share behavior; otherwise duplicate | | STI | A `kind` column + `DeriveActiveEnum` | | Polymorphic associations | Avoid. Separate nullable FKs with a `CHECK` that exactly one is set | | `enum status: {...}` | Text column + `CHECK` constraint + `DeriveActiveEnum` | | `counter_cache` | Compute with `COUNT(*)`; denormalize only for a measured reason | | `dependent: :destroy` | `on_delete(Cascade)` on the FK in the migration | | Soft delete (`paranoia`) | `discarded_at TIMESTAMPTZ` + queries filter it | | `touch: true`, `updated_at` | Set `updated_at = Set(Utc::now())` in every update fn | | Transactions | `let txn = kernel::db::begin(db).await?; … txn.commit().await?` (BEGIN IMMEDIATE on SQLite) | | `find_each` | Keyset pagination loop (`WHERE id > $1 ORDER BY id LIMIT 1000`) | | Pagination (pagy/kaminari) | `Page` struct in `models/mod.rs` → `.limit().offset()` | | Search (pg_search/ransack) | Portable `LIKE`; full-text via the escape hatch (tsvector / FTS5) | ## Action Controller concepts | Rails | Off Rails | |-------------------------------|-----------| | `before_action :authenticate` | `RequireUser` extractor as a handler argument | | `params.require(:post).permit(...)` | `Form`. The struct *is* the permit list | | `redirect_to posts_path, notice:` | `Flash::notice(jar, "…")` + `Redirect::to(&paths::posts())` | | `render :new, status: :unprocessable_entity` | Re-render form view with errors, status 422 | | `respond_to { format.json }` | Separate API handlers under `/api/v1`; no content negotiation | | `rescue_from` | `AppError` → `IntoResponse` in `error.rs` | | `helper_method :current_user` | `CurrentUser` extractor passed into the view struct | | Strong parameters | Typed `Deserialize` structs; unknown fields ignored | | `posts_path`, `post_path(@p)` | `paths::posts()`, `paths::post(id)` functions in `routes.rs` | | Pundit / CanCanCan | `policies/.rs` pure fns: `can_edit(user, post) -> bool` | ## Borrowed from Phoenix / Elixir Rails isn't the only source. Where Elixir did it better, we copied it: | Phoenix / Elixir | Off Rails | Guide | |------------------|-----------|-------| | Oban (jobs as rows with a state machine, kept history, queue limits, pause/resume, snooze/cancel, unique jobs) | `kernel/jobs/` | [jobs](guides/jobs.md) | | Oban Web | `/admin/jobs` (live, htmx-polled) | [jobs-dashboard](guides/jobs-dashboard.md) | | Oban Cron plugin | `src/jobs/recurring.rs` + `recurring_executions` | [jobs](guides/jobs.md#recurring-jobs-srcjobsrecurringrs-the-oban-cron-plugin) | | Oban Lifeline / Pruner / Stager plugins | Kernel plugins run by every worker | [jobs](guides/jobs.md#plugins-pluginsrs-every-node-runs-all-of-them-all-idempotent-no-leader-election) | | `Oban.Testing` (`assert_enqueued`, `perform_job`, `drain_queue`) | `TestApp` helpers | [jobs](guides/jobs.md#testing-obantesting-equivalents) | | Swoosh local adapter + `/dev/mailbox` | `Mailer::Local` + `/dev/mailbox` | [mailers](guides/mailers.md) | | Phoenix LiveDashboard | `/dev` (modest: mailbox, jobs, sockets, routes, system) | [dev-tools](guides/dev-tools.md) | | Phoenix.Channel + phoenix.js | Inspired by Phoenix Channels; **our own protocol**, offrails.v1: `/socket` (+ SSE and long-polling fallbacks), typed `Room` actors, `seq` resume, `static/channels.js` | [channels](guides/channels.md) | | Phoenix.Presence | `Hub::track`/`untrack`, `presence` frames (`here`, `joins`/`leaves`); people, not tabs; minimal metadata | [channels](guides/channels.md) | | Phoenix.PubSub | per-topic in-process broadcast + Postgres `NOTIFY` between nodes | [channels](guides/channels.md) | | libcluster / Horde | `kernel/cluster.rs`: `cluster_nodes` heartbeats, rendezvous ownership, failover rebuilt from the DB | [channels](guides/channels.md#more-than-one-server-kernelclusterrs) | | Phoenix / Rails pending-migrations page (`ActiveRecord::PendingMigrationError`) | Dev-only page with a **Run migrations** button; `/up` 503 in production while pending | [dev-tools](guides/dev-tools.md#pending-migrations) | | Ecto changesets | `Params::validate() -> Result, Errors>` | [models](guides/models.md#validations) | | `mix phx.routes` | `app routes` / `/dev/routes` | [cli](cli.md) | ================================================================ FILE: CHANGELOG.md (https://rustoffrails.org/docs/changelog.md) ================================================================ # Changelog Each entry lists convention changes and what an existing app must do about them. `/offrails:upgrade` reads this file, so write the **App migration** notes as instructions for an agent. Every release section has the same shape, in this order, because `tools/release/release.sh` copies it verbatim into the GitHub Release: **What's new** (4–6 plain bullets: what you can do now and why it matters), **Breaking changes** (what an existing app must act on, or "None."), **Upgrading** (a pointer to the ordered steps and [upgrade](generators/upgrade.md)), then **Full list of changes** (the detailed entries with their App migration notes). Subsections are `###`, so the release script's `## (` heading match still finds the section's end. ## 0.7.3 (2026-10-19) ### What's new - **Snippets beside generators.** `cargo offrails snippets` lists small, tested recipes (an Atom feed, slugs, Markdown, pagination, a theme toggle, backdated seeds, a public page by slug, a CRUD resource); `snippet show` prints one to read or copy, `snippet add` copies and wires it, `snippet remove` takes it back. Both snippets and generators are optional starting points. - **Generated code is small files with clear insert and remove points.** A resource is a folder per layer (controllers per action pair, the model's entity, params and queries, a routes file, tests by concern), and every line it adds to a shared file is marked with the feature's name, so `destroy` or a person can find and remove it. Path parameters work in `g controller` and `g page`. - **Polish from one file and a component kit.** `brand.toml` with `cargo offrails brand` sets the colors (contrast-checked), fonts, radius, logo, favicon set and social card; five layouts (top nav, sidebar, marketing, auth, docs); a kit of Askama components (forms with errors, tables, pagination, cards, modals, a confirm dialog, toasts) shown in every state at `/dev/components`. Generated and snippet pages are built from the kit, in a layout you pick with `--layout`. - **`cargo offrails prove`.** With the app running, it screenshots every page at desktop and phone width in light and dark, checks console errors, CSP, accessibility (axe), links and sideways scrolling, and writes `docs/proof/PROOF.md`, which ties each acceptance criterion to its passing tests and the pages they touch. - **Invite-only sign-up is a setting.** `DEFAULT_SIGNUP` in `src/config.rs` (or `APP_AUTH_SIGNUP=open|invite`) closes sign-up for every sign-in method, and `app task auth_invite ` lets people in; `cargo offrails new … --invite` starts an app that way. - **Security review fixes** (template, tooling and guides): an unset `APP_ENV` now refuses to boot instead of defaulting to development; `APP_TRUSTED_PROXY_HOPS` picks which `X-Forwarded-For` hop is the client behind several proxies; `/static` marks a URL immutable only when its `?v=` is the file's current digest; `Cache::delete_prefix` is one escaped `LIKE` delete; `cargo offrails verify` names every skipped step in its verdict and never probes a local Postgres; `--pitch` is validated like the title; the Claude Code hook guards canonicalize paths and match `pkill` behind paths, quotes and subshells, and fail closed without jq. **Guides corrected:** sea-orm-migration takes no lock, so exactly one process migrates per deploy; every proxy needs `APP_TRUSTED_PROXY=true`; blob downloads are `no-store`; API tokens die with a password reset and expire by default; webhook routes verify signatures; search escapes `LIKE` wildcards; the realtime guide says plainly that its signals/streams design is a spec the parts do not implement yet. - **Fixes from real builds and upgrades:** a huge `?page=` no longer crashes generated lists, an upgraded app no longer runs the previous release's `cargo offrails`, `verify --full` accepts a separate coverage toolchain, the browser smokes find any password sign-in form, the Dockerfile stops compiling dependencies twice, and `offrails-check` warns about `hx-trigger` filters that the CSP setup silently disables. ### Breaking changes - **Generators and `snippet add` need two 0.7.3 template changes first**, and refuse (naming what's missing) without them: the feature routes anchors in `src/routes.rs` (`ROUTES` becomes `APP_ROUTES` + `TABLES`; steps 3–4 below), and the component kit (`templates/components/ui.html` and its helpers; step 6). - **The generated file layout changed.** New generations write small files per feature; `destroy` of a `resource`, `model`, `controller`, `page` or `api` generated before 0.7.3 refuses and lists its files (delete them by hand, then `destroy … --forget`). - **The `ACCOUNTS` header has new words:** sign-in methods joined by `+`, then `invite` (`signup` → `password`, `invite` → `password invite`, `magic-link-invite` → `magic-link invite`). `add-part` reads the old ones; rewrite the line (step 8). - **Build the new release's offrails-gen in its own target directory** (`target/offrails-gen-`) when upgrading, not `target/offrails-gen`. - **`APP_ENV` is required.** `app …` fails with `APP_ENV is not set` when neither the environment nor `.env` sets it. Every server already sets `APP_ENV=production` (the Dockerfile does); locally `bin/setup` writes `APP_ENV=development` to `.env`. CI scripts that ran the binary without it must set it. - `Config` gains `trusted_proxy_hops: u8` (default 1); `Config::boot_warnings` no longer warns about an unset `APP_ENV`, and `env_defaulted` is always `false`. - No MSRV or toolchain change. ### Upgrading Follow the numbered steps below in order (each names the entry whose App migration has the details), and [upgrade](generators/upgrade.md) for the whole procedure (`/offrails:upgrade` runs it). **Upgrading an app to 0.7.3, in order** (each step's Detect/Converge/Prove is in the entry it names below; [upgrade](generators/upgrade.md) is the whole procedure). `$FW` is the new framework. Build the new release's offrails-gen in its own target directory, `--target-dir "target/offrails-gen-$(cat "$FW/VERSION")"`, never `target/offrails-gen` (From a real app's upgrade, below). 1. **Parts first, before re-vendoring**, as in 0.7.2: `cargo offrails update-part --framework "$FW" --dry-run`, then without it. The parts are synced from samples `2bc93ad` (Parts below: invite-only as a switch, `paths::after_sign_in()`/`account()` in their tests, the mailbox, phone widths). An app on 0.7.1 or older runs the new release's `offrails-gen` for this one step ([upgrade step 6](generators/upgrade.md#6-update-the-parts-then-re-vendor-the-framework)). 2. **Re-vendor:** `cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" --target-dir "target/offrails-gen-$(cat "$FW/VERSION")" -- vendor --framework "$FW"`. `cargo offrails snippets`, `snippet …`, path params, `--layout` and `new --invite` now work. 3. **Feature routes files** (Generators → App migration): `src/routes.rs` gets `route mods` and `route tables` anchors and `ROUTES` becomes `APP_ROUTES` + `TABLES`. Required before the first `generate resource|controller|page|api` or `snippet add` (they refuse and name the anchor without it). 4. **The route sweeps fill any `{param}`, and the mounted-route check reads `src/routes/`** (same entry): required with the step above. 5. **`samples: app js`** at the end of `static/app.js` (Snippets → App migration): required before `snippet add theme-toggle`. 6. **The component kit** (Polish and proof → App migration, *Layouts and components*, and Generators and snippets on the kit → App migration): required before `generate resource|controller|page` (they refuse without it, naming the files) and before `snippet add resource|pagination|public-show-by-slug`. `brand.toml`, the other layouts and `prove` stay optional. 7. **Template fixes** (Template → App migration, and From a real app's upgrade → App migration): the `?page=` clamp (do this one: every generated index answers a huge page with a 500 otherwise), the pending-migration and seed tests, the AGENTS.md placeholder, `COPY .cargo .cargo` in the Dockerfile's dependency layer, the `.scroll` box in `static/kernel/admin.css`. Each is independent. 8. **The `ACCOUNTS` header** (Accounts → App migration): rewrite it in the new grammar (`signup` → `password`, `invite` → `password invite`, `magic-link-invite` → `magic-link invite`); an invite-only app sets `DEFAULT_SIGNUP = Signup::Invite` (after step 1 brought auth-core's switch). 9. **Generations recorded before 0.7.3** keep working as code; `destroy` of a `resource`/`model`/`controller`/`page`/`api` from before refuses and lists its files (Generators → App migration). 10. **Optional polish** (Polish and proof → App migration): `brand.toml` (`cargo offrails brand --init`), the layouts, and `cargo offrails prove`, which `verify --full` runs when `BASE` is set. 11. **Security review** (Security review fixes, above): Add `trusted_proxy_hops: 1` to any hand-built `Config` literal (`Config::test()` has it). Set `APP_TRUSTED_PROXY_HOPS` where more than one proxy appends to `X-Forwarded-For`. Replace the `an_unset_app_env_warns_at_boot` unit test with `an_unset_app_env_refuses_to_boot` in `tests/app/kernel_test.rs` (config.md shows it). 12. **Prove it:** `cargo offrails verify --full` is green on SQLite and Postgres, `sh .offrails/tools/offrails-check` reports 0 errors, `BASE=… cargo offrails prove` says proven, and `docs/decisions.md` has `YYYY-MM-DD: upgraded to framework 0.7.3`. ### Full list of changes - **Snippets: small, tested code to copy** ([snippets](template/snippets/README.md)). The human, after a timed blog build on 0.7.2 (43 min, most of it friction, ~2,240 hand-written lines): "make generators kinda optional, i guess i still prefer snippets to generators... or generators that have good insertion or removal points. ideally multiple smaller files." The blog had hand-written (or fought) a feed, slugs, Markdown, the pager, a theme button, backdated seeds and a public page by slug. Each is now a snippet in `template/snippets//`: a README (what, when, the files, the anchors it wires into, how to remove it), a `snippet.toml` in a part's `sample.toml` format (plus `as`, the model `--as` renames, and `snippets`, the snippets it builds on) and `files/` at app paths. - **The eight:** `resource` (htmx CRUD for `notes`, public: exactly the files `g resource notes title:string body:text --public` writes; a test in offrails-gen keeps the two identical), `atom-feed` (`GET /feed.xml`, escaped by Askama, RFC 3339 dates, newest 20, `` on every page), `slug` (`slugify`, accents folded, 80 chars; `unique(base, query)`: `-2`, `-3`…, per owner or global), `markdown` (pulldown-cmark + ammonia; tests for ``; extend the browser smoke with Forward from a 404. ## 0.3.0 (2026-10-13) ### What's new - **Open source** (MIT OR Apache-2.0), with a one-line install (`curl -fsSL https://rustoffrails.org/install.sh | sh`) and the `offrails` command (`new`, `doctor`, `version`, `update`, `uninstall`). - **Security headers in one middleware** (`kernel/security.rs`), a `__Host-` session cookie in production, a STRIDE section in every feature spec, and CI that runs cargo-deny, gitleaks and a ZAP scan. - **API-only apps and a public API:** `FRONTEND_MODE: api` and an `API: none | rest | graphql | both` header line; REST under `/api/v1` with bearer tokens and OpenAPI, or GraphQL. - **Svelte as a UI library,** chosen with a new `UI_LIBRARY` header line. - **Realtime channels:** one WebSocket per tab, rooms with presence, an opt-in Postgres cluster layer, and faster signals (`NOTIFY` on Postgres: 238 ms to 2.1 ms). - **`compose.yaml` runs the whole app,** and every app with pages gets favicons, social cards, `robots.txt` and a sitemap from day one. ### Breaking changes - `FRONTEND_MODE: react` is renamed `spa`, and the app's `AGENTS.md` header gains `UI_LIBRARY` and `API` lines. - The production session cookie is renamed `__Host-session_token`: deploying logs everyone out once. - The frontend type-check is `pnpm --dir web typecheck`; `web/package.json` needs a `typecheck` script. - Every mounted route must have a `ROUTES` row (a new test fails otherwise). ### Upgrading Each subsection's **App migration** list below. The procedure is in [upgrade](generators/upgrade.md). ### Full list of changes The open-source release. Rust off Rails is public at https://github.com/rust-off-rails/rustoffrails and installs with one line: `curl -fsSL https://rustoffrails.org/install.sh | sh`. #### One-line install and the `offrails` command - **`install.sh`** (POSIX sh): shows its plan first, `--dry-run`, `--yes`; Rust via rustup (no sudo); sea-orm-cli, cargo-nextest and cargo-watch as prebuilt binaries via cargo-binstall; a SHA-256-verified framework release in `~/.offrails/` with a `current` symlink; installs the Claude Code plugin when `claude` is present; prints the AGENTS.md prompt for Codex and Cursor. Idempotent, no telemetry. See [INSTALL.md](INSTALL.md). - **`offrails`**: `new ""`, `doctor`, `version`, `update`, `uninstall`. - New apps also get `.cursor/rules/offrails.mdc` and `.github/copilot-instructions.md`, pointing to `AGENTS.md`. **App migration:** optional. Add the two agent pointer files above. #### Open source - Dual licensed **MIT OR Apache-2.0**; `SECURITY.md` (GitHub private vulnerability reporting), `CONTRIBUTING.md` (contributions are intent plus evidence), Code of Conduct, issue forms ("Agent got stuck" first), and CI: links, hook tests, plugin validation, gitleaks, installer tests, OpenSSF Scorecard. - Brand: the `\R/` derailing mark and the hopping "off" wordmark, drawn as code (`brand/`). - Doctrine §0, **"All code is written by AI"**, is now the founding rule. - Plugin: `/offrails:security` skill; hooks for security tripwires, banned crates in `Cargo.toml`, append-only committed migrations, raw `.begin()`, and no `pkill`; content-fingerprint drift detection; `/offrails:upgrade` reconciles instead of re-creating. #### Security as the gold standard: Non-negotiables, the header matrix, automated enforcement Spec: `guides/security.md` (Non-negotiables, the header matrix, Automated enforcement, Gotchas), plus `blueprints/feature-spec.md` (a `## Security` STRIDE section), `workflow.md`, `guides/test-standards.md` §3, `guides/testing.md` CI, `generators/new.md`, `stack.md`, `structure.md`, `guides/routing.md` and `guides/auth.md`. Plugin: the `/offrails:security` skill and security tripwires in the hooks. Verified on the reference app (branch `security`): 207 tests on SQLite and Postgres 18; cargo-deny `advisories/bans/licenses/sources ok`; gitleaks clean over the history; ZAP baseline 0 FAIL, 0 WARN, 61 PASS; checked in Chrome. - **`kernel/security.rs`**, one middleware outside the panic and timeout layers: - the full header matrix, adding `object-src 'none'`, COOP and CORP to what was there; - `upgrade-insecure-requests` and HSTS only with an https `APP_URL`; - `no-store` on every authenticated response; - production cookies forced to `Secure; HttpOnly; SameSite=Lax`. - **Session cookie:** `__Host-session_token` in production, and production ignores the plain name. - **CI:** - a `security` job (cargo-deny + gitleaks over the full history); - the Docker job boots the production image and runs the ZAP baseline; - Dependabot. - **Findings fixed:** - the flash cookie had no `Secure` in production; - authenticated pages were cacheable; - panic 500s and timeout 408s had no security headers. **App migration** (reconcile, don't re-create): 1. **Header middleware.** Detect: `src/kernel/security.rs`. - Missing: generate it per `guides/security.md#the-header-matrix`. In `routes.rs`, replace the per-header `SetResponseHeaderLayer`s and the `CSP` const with one `.layer(from_fn_with_state(security::Hardening::new(&config), security::middleware))` placed **after** (outside) `CatchPanicLayer`. - If a route sets its own CSP (the mailbox viewer), keep it. The middleware only fills in missing headers. 2. **Session cookie.** Detect `PROD_COOKIE` in `kernel/session.rs`. Missing: add `name(secure)` and make `read(headers, secure)` accept only `name(secure)`. Update `cookie`/`clear_cookie`, the `load_session` caller and any WebSocket upgrade that reads the cookie. Tests use `ksession::name(config.is_production())`. Deploying logs everyone out once, because the cookie name changes. Say so to the human. 3. **`tests/app/headers_test.rs`.** Add it (one row per response class the app has), plus `TestApp::request_head`. Production rows use an `https://` `APP_URL`. Make it fail first if the middleware is off. 4. **Scanner configs.** Add `deny.toml` (copy the reference in `security.md`; keep any `[bans]` the app already has), with `publish = false` in both crates. Add `.gitleaks.toml`, `.zap/rules.tsv` (+ `.zap/.gitignore`) and `.github/dependabot.yml`. Run each scanner locally. Fix findings, or triage them **with a reason** in the config. 5. **CI.** Add the `security` job and the Docker job's boot + ZAP steps from `guides/testing.md#ci`. Lint with actionlint. 6. **Feature specs.** Add a `## Security` section to every `product/features/*.md` (`blueprints/feature-spec.md`). Each threat gets a control and a test. A threat without a test is a finding. #### API-only apps, the `API` axis (REST + OpenAPI, GraphQL), Docker compose, day-one meta and favicons Spec: `guides/api.md` and `guides/graphql.md` (new), `tools/brand/render.sh` (new), plus `interview.md`, `blueprints/AGENTS.md`, `generators/new.md`, `generators/scaffold.md`, `guides/views.md`, `guides/deployment.md`, `guides/auth.md`, `guides/controllers.md`, `guides/config.md`, `guides/test-standards.md`, `stack.md`, `structure.md`, `cli.md`, `RAILS_MAP.md`. Verified with the `offrails-api-demo` app (`FRONTEND_MODE: api`, `API: both`): 151 tests green on SQLite and Postgres 18, clippy clean, GraphiQL and the Scalar reference checked in Chrome, `docker compose up --build` from scratch plus the `mail` and `worker` profiles, and direct TLS (h2 via ALPN). - **`FRONTEND_MODE: api`** (`rails new --api`): no UI, JSON errors everywhere except `/dev` and `/admin`, `GET /` as a JSON index, `/robots.txt` disallowing all. - **`API: none | rest | graphql | both`**, a separate header line, chosen in the interview. REST lives under `/api/v1`: - bearer API tokens (`api_tokens`, SHA-256 digests, scopes, expiry); - `ApiJson` (JSON error shape for bad bodies) and `{"data": …}` envelopes; - an OpenAPI document via utoipa, committed as `docs/openapi.json` with a drift test, plus Scalar at `/dev/api-docs`. - GraphQL lives at `POST /graphql`: async-graphql, DataLoaders, depth (≥ 13, or introspection breaks) and complexity limits, payload `UserError`s, `docs/schema.graphql` with a drift test, GraphiQL at `/dev/graphiql`. - CORS comes from `APP_CORS_ORIGINS`: exact origins, also trusted by CSRF, no credentials. Per-token rate limits. - **`compose.yaml` runs the whole app**: the production image plus Postgres with healthchecks, and profiles `worker` and `mail`. New `app health` subcommand for container healthchecks. - **Day-one brand and meta for every mode with pages:** - `tools/brand/render.sh` writes `favicon.svg`, `favicon.ico`, `apple-touch-icon.png`, `icon-512.png` and `og.png`; - `Ctx` carries the title, description and canonical URL; - the layout has Open Graph and Twitter tags; - `/robots.txt` and `/sitemap.xml`; - smoke tests assert all of it. - **Web server**: documented as axum on hyper (HTTP/1.1 + h2c); optional direct TLS via `APP_TLS_CERT`/`APP_TLS_KEY` (axum-server + rustls). - **Fixed:** `serde_html_form` is a kernel dependency (the jobs dashboard's filter links). `new` adds it again, which corrects the earlier doc-fix pass. **App migration** (reconcile, don't re-create): 1. **Header.** If `AGENTS.md` has no `API:` line, add `API: none` under `ISLANDS:`. An app that already serves a public API (tokens, `/api/v1`) records `rest`, and follows `guides/api.md` for anything it lacks. 2. **compose.yaml.** Detect: `grep -q '^ app:' compose.yaml`. - If it has only `postgres`/`db`, replace it with the shape in `guides/deployment.md#docker-compose-the-whole-app-locally`. Keep the existing Postgres major and volume name (renaming the volume loses local data; say so). - Add `app health` to `src/main.rs` if missing. - Verify with `docker compose up --build`, then `down -v`. 3. **Brand and meta** (modes with pages). - Detect: `static/og.png`, `static/apple-touch-icon.png`, and `og:image` in the layout or shell. - Missing: run `bash .offrails/tools/brand/render.sh "" "" "" static`, add the `` tags and the `Ctx` meta fields from `guides/views.md#meta-favicon-and-social-cards-every-app-with-pages`, route `/favicon.ico`, `/robots.txt`, `/sitemap.xml`, and add the smoke-test assertions. - An existing hand-made favicon or OG image wins. Don't overwrite it; only fill in what's missing. 4. **serde_html_form.** Ensure it's in `Cargo.toml` (`cargo add serde_html_form`). It's harmless if already present. 5. Adding `API: rest | graphql` to an existing app is a feature, never part of an upgrade. Spec it, then follow `guides/api.md` / `guides/graphql.md` and `generators/scaffold.md` step 9b per resource. #### Svelte as a UI library; the `react` mode is renamed `spa` Spec: `guides/frontend-svelte.md` (new); `stack.md`, `interview.md`, `blueprints/AGENTS.md`, `structure.md`, `generators/new.md` and `generators/scaffold.md` updated. Verified in Chrome: - Inertia + Svelte 5 on the reference app: every protocol check, 409 redeploy reload, HMR, Playwright, 75 Rust tests on SQLite and Postgres; - a SvelteKit SPA served by Rust under a strict hashed CSP; - a Svelte island. - **The app header gains `UI_LIBRARY: react | svelte | none`.** `FRONTEND_MODE` values are now `htmx | inertia | spa | api` (`api` is specified by the API-only work). - **`FRONTEND_MODE: react` is now `spa`.** Same architecture; the name no longer implies the library. - **The DoD's frontend type-check is `pnpm --dir web typecheck`.** React keeps `"typecheck": "tsc -b"`; Svelte uses `svelte-check --fail-on-warnings`. **App migration** (existing apps; reconcile, don't regenerate): 1. Detect `FRONTEND_MODE:` in `AGENTS.md`. - If it says `react`, change it to `spa` and add `UI_LIBRARY: react` under it. - If it says `inertia`, or `ISLANDS: yes`, add `UI_LIBRARY: react`. - Otherwise add `UI_LIBRARY: none`. No code changes. Log the header change in `docs/decisions.md`. 2. If `web/package.json` has no `typecheck` script, add `"typecheck": "tsc -b"`. CI and the DoD now call `pnpm --dir web typecheck`; update `.github/workflows/ci.yml` if it runs `exec tsc -b` directly. 3. Switching an existing app to Svelte is a human decision, never part of an upgrade. If asked, follow `guides/frontend-svelte.md`: the Rust side changes only `VITE_ENTRY` and drops the React refresh preamble route. #### Realtime: channels, presence, the hub, the cluster layer Spec: `guides/channels.md` (new), plus changes to `guides/realtime.md`. Verified in the reference app (book page: "reading now" avatars and a live discussion), with 21 tests on SQLite and Postgres 18 including a 3-node failover, a Playwright smoke, and a manual server-restart check in Chrome. - **Signals got fast.** `pubsub::broadcast` now wakes the poller: `NOTIFY` at commit on Postgres (238 ms → 2.1 ms) and an in-process `Notify` on SQLite (→ 0.24 ms). The 250ms poll remains as a backstop. New `PubSub::start_with(poll_every)`. - **Channels:** - `GET /socket`: one WebSocket per tab, Phoenix v2 wire format, topics joined with authorization; - `kernel/hub.rs`: a room actor per topic, supervised; serialize-once fan-out; slow consumers dropped; presence by person; - `kernel/channels.rs`: Origin check, auth at upgrade, frame and rate limits; - `static/channels.js`, and `/dev/sockets`. - **Cluster (opt-in, Postgres):** `kernel/cluster.rs`, with `cluster_nodes` heartbeats, a `NOTIFY` bus, rendezvous ownership, forwarded calls, presence cleanup and failover. Settings: `APP_CLUSTER`, `APP_NODE`. - **Fixed:** the reference app's `ROUTES` table had no rows for books, reviews, blobs or streams, so the access matrix never tested them. New test `every_mounted_route_is_in_the_routes_table` parses `src/routes.rs` and fails on any mounted path missing from `ROUTES`. **App migration** (an agent upgrading an existing app; reconcile, don't re-create): 1. **Every app: route table drift.** - *Detect:* any `.route("…")` path in `src/routes.rs` without a `ROUTES` row. - *Converge:* add the rows with their real `Access` (check each handler's extractor). Then add the `every_mounted_route_is_in_the_routes_table` test from `guides/test-standards.md` §3 (`tests/app/security_test.rs`), and run the security suite, which may now fail on routes it never covered. Fix those routes, not the test. 2. **Apps with SSE channels: faster signals.** - *Detect:* `kernel/pubsub.rs` without `pg_notify` / `WAKE`. - *Converge:* add the `NOTIFY` (Postgres) and `WAKE` (SQLite) calls in `broadcast`, `start_with`, and the dedicated listener task, per realtime.md's kernel spec. - *Tests:* the wake-up test (`start_with(60s)`) and the rollback test. - No schema change. 3. **Apps that need rooms or presence:** only when a spec asks for them. Run `generators/channel.md` → room recipe. - *Detect:* an existing hand-rolled `axum::extract::ws` handler. - *Converge:* move its topics into `Room` impls behind `/socket`. Keep the old route as long as clients depend on it, and log the deviation. ## 0.2.0 (2026-10-12) ### What's new - **A full persistent job queue** (Oban-style: queues, timeouts, cancellation, cron, unique jobs, graceful shutdown) and the `/admin/jobs` dashboard to inspect and act on it. - **A dev mailbox** at `/dev/mailbox`: every email sent in development, as HTML, text or raw, on desktop or mobile, with previews and JSON for agents. - **`/dev` and a pending-migrations page:** in development, pending migrations show a page with a Run button instead of a broken app. - **Magic-link sign-in** as a first-class accounts variant, and a `TENANCY` header line. - **Safer data changes:** every transaction starts with `kernel::db::begin` (`BEGIN IMMEDIATE` on SQLite, so the job worker can't cause "database is locked"), and committed migrations are append-only. - **Test standards:** acceptance-criteria ids, an access matrix over every route, tenant isolation, coverage gates and a real-browser smoke. ### Breaking changes - `DeliverEmailJob` becomes a struct (rows already queued still decode), mailer selection moves to `state::select_mailer`, and development always uses the local mailbox. - `/dev/mailers` becomes a 301 to `/dev/mailbox/previews`. - A plain `db.begin()` outside the kernel is flagged; use `kernel::db::begin`. Edits to committed migrations fail CI and are blocked by the plugin. - The migration crate drops its `cli` feature and `migration/src/main.rs`. - Postgres test databases are named `test__`. - New settings `MAILER` and `APP_REVISION`; `APP_URL` defaults to localhost in development; production refuses to boot without `static/app.css`. ### Upgrading The numbered **App migration** list (steps 1–20) at the end of this section. The procedure is in [upgrade](generators/upgrade.md). ### Full list of changes The kernel UIs, verified end to end in a real app (bookshelf, htmx, SQLite + Postgres 18, Chrome): the full Oban-style queue and `/admin/jobs`, a Swoosh-grade dev mailbox, `/dev`, and a pending-migrations page. Specs: `guides/jobs.md`, `guides/jobs-dashboard.md`, `guides/mailers.md`, `guides/dev-tools.md`. - **Jobs:** producers per queue with semaphores, an optimistic claim, timeouts, panic capture, cancellation of executing jobs (Heartbeat aborts), snooze/cancel/discard, the Stager/Lifeline/Pruner/Cron plugins as plain functions, uniqueness via `on_conflict_do_nothing_on` → `TryInsertResult::Conflicted` (sea-orm 2), and graceful shutdown. `JobBuilder` is non-generic (`JobDefaults` + serialized args). `Recurring { key, cron, job: fn() -> JobBuilder }`; cron jobs are tagged `cron` with `meta.cron = key`. - **`/admin/jobs`:** overview (counters, throughput SVG, queues, nodes), filtered list with bulk actions and an "all matching" confirmation page, job timeline + error history, cron page, live polling, flashes, `no-store`. - **Dev mailbox:** two panes (Inbox/Previews), Cc/Bcc/Reply-To, attachments, HTML/Text/Raw, Desktop/Mobile, links, `.eml` at `/dev/mailbox/{id}/message.eml`, new-mail banner, test-email button, JSON for agents. `/dev/mailers` → Previews tab. - **Mailer selection:** development is always `Mailer::Local` (`MAILER=smtp|local|log` overrides); `Log` is only the production fallback. Selection moved from the kernel into `state::select_mailer`. - **Email:** `Email` gains `cc`, `bcc`, `reply_to`, `attachments` (all `#[serde(default)]`). `DeliverEmailJob` becomes `{ #[serde(flatten)] email, expires_at }` with `MAX_ATTEMPTS = 10`; `mailers::deliver_later_until` for time-limited mail without secrets. Token emails use dedicated ids-only jobs (no secrets in job args). The one mail layout is `templates/mailers/layout.html`. - **Back/Forward and hx-boost:** kernel routers are `no-store`, the kernel layout has `historyCacheSize: 0` and `static/kernel/admin.js` (pageshow reload), gone records redirect with a flash, and app links into kernel pages carry `hx-boost="false"`. - **Pending migrations:** a dev-only page with a Run button, `/up` 503 while pending, the worker waits, and `bin/dev` migrates. - **Config:** in development `APP_URL` defaults to `http://localhost:{PORT}`. New `MAILER`, `APP_REVISION`. - **Migration crate:** `sea-orm-migration` without its `cli` feature; no `migration/src/main.rs`. - **Tooling:** `.offrails/tools/browser-smoke/` (optional; puppeteer-core cached outside the app). - **SQLite transactions:** every transaction starts with `kernel::db::begin(&db)` (`BEGIN IMMEDIATE` on SQLite). A plain `db.begin()` is deferred and fails with "database is locked" when the job worker writes between your read and your write. Tests missed it because the in-memory test database has one connection; a regression test now uses a real file. The plugin flags `.begin().await` outside the kernel. - **Migrations are append-only once committed.** Edited applied migrations never reach existing databases. CI fails a change that modifies a committed migration, and the plugin blocks such edits. - **Errors on boosted pages are visible.** `static/app.js` shows the server's error page for 5xx on boosted requests, and the layout's `responseHandling` swaps 422 and 429. - **Auth:** - magic-link login is a first-class variant (`ACCOUNTS: magic-link | magic-link-invite`), recommended in the interview. It's fully specified in auth.md: POST to consume, single use, 15 minutes, enumeration-safe, rate-limited per IP and per email; - the dev "check your mailbox" hint on every auth screen that sends email; - every auth email has a mailer preview; - failed admin Basic-auth passwords are rate-limited (10 per 3 minutes per IP); - `TENANCY: per-user | teams | shared | none`. - **Live updates (SSE):** events carry non-empty `data` (browsers drop empty ones); one `/streams/{stream}` route; streams end on shutdown; the app layout uses `historyCacheSize: 0` and `refreshOnHistoryMiss: true`. - **CSS check:** development warns and shows a banner when `static/app.css` is missing or empty; production refuses to boot without it. - **Errors:** the 422 fallback renders `templates/errors/_invalid.html` (never HTML from `format!`). The router fallback marks its 404 with `x-route-fallback: 1`, so the routability test is exact for parameterized routes. - **Tests:** `test-standards.md` (AC IDs, archetype suites, the access matrix over `ROUTES` with an `Access` column, tenant isolation, coverage and mutation gates with a per-path `jq` check, query-count guard, real-browser smoke). `TestApp` gains `assert_text`, `without_fetch_metadata`, `post_multipart`, `get_htmx` and SSE helpers. Postgres test databases are `test__`, and `test_db_cleanup` drops only this app's idle ones. - **New guidance:** dark mode (OS-following by default; a no-flash cookie toggle with a verified Tailwind 4 variant); public pages (meta, OG image, canonical, robots, sitemap, self-hosted fonts, Lighthouse ≥ 95); trusted vs user Markdown (heading ids, ammonia builder); runtime-read directories in the Dockerfile; how an agent runs, checks and stops the app (never `pkill` by name). - **Plugin:** drift is detected by content fingerprint, not just the version line. Hooks block `pkill`/`killall` by name and edits to committed migrations, and flag `.begin()` outside the kernel and `sleep` in tests. `/offrails:upgrade` reconciles instead of recreating. - **Docs fixes:** sea-orm 2 pitfalls (`ActiveModel::default()` ambiguity, OFFSET without LIMIT on SQLite, jsonb text search), axum's whole-segment params, the CSP ⇄ htmx-config pairing (verified in Chrome), lettre quoted-printable links. **App migration (reconcile, don't recreate).** For each item: detect what the app already has, compare with the final shape, then converge. Classify each as *new*, *present-conforming*, *present-divergent* or *conflicting*. Never edit or re-run an applied migration; add a new one. Commit per item, green on both databases. 1. **`mailbox_messages` table.** Detect: the table exists (any `m…_create_mailbox_messages`, applied). Final columns: `id, to_addr, from_addr, cc json [] , bcc json [], reply_to string null, subject, html text, text text, headers json, eml text, attachments json [], read_at timestamptz null, created_at`. Converge: generate `m_alter_mailbox_messages` that adds only the missing columns (SQLite-safe: one `ALTER TABLE … ADD COLUMN` per column, with defaults; nullable `read_at`). If the app named the raw message something else (e.g. `raw_eml`), keep the column and map the entity field with `#[sea_orm(column_name = "raw_eml")] pub eml: String` rather than renaming. Missing table: *new*, so generate `create_mailbox_messages`. 2. **`Email` and delivery.** Detect: `src/kernel/mailer.rs::Email` fields, `Mailer` variants, whether selection lives in the kernel (`Mailer::from_config`). Converge: add the four `#[serde(default)]` fields and `Attachment`; extend `build()` (Cc/Bcc/Reply-To, multipart/mixed); add constructors `Mailer::smtp/local/memory`; move selection to `state::select_mailer` with the table in mailers.md. **Delete any file-writing/"logged locally" dev mailer**: development must be `Local`. Update the `EMAIL` header line to `dev: local mailbox (/dev/mailbox); prod: …`. 3. **`DeliverEmailJob`.** Detect: tuple struct `DeliverEmailJob(Email)`. Converge: `struct DeliverEmailJob { #[serde(flatten)] email: Email, #[serde(default)] expires_at }` (rows already queued as a bare `Email` still decode; add the decode test), `MAX_ATTEMPTS = 10`, expiry discard; add `mailers::deliver_later_until` for time-limited mail **without secrets**. Any email carrying a token (password reset, login link, confirmation, invitation) must not be enqueued rendered: detect `deliver_later(…)` calls whose email embeds a token, and replace each with a dedicated ids-only job (5 attempts, discard when stale) that mints the token, stores its digest and delivers (guides/auth.md). 4. **Dev mailbox UI.** Detect: `kernel/mailbox.rs` routes (`/dev/mailbox`, `/dev/mailbox/{id}`, any `/{id}.eml` or `/{id}/raw`), templates under `templates/kernel/mailbox/` or elsewhere, a separate `/dev/mailers` page. Final: the route table in mailers.md (`/dev/mailbox`, `/{id}`, `/{id}/message.eml`, `/{id}/html`, `/{id}/attachments/{n}`, `/{id}/delete`, `/clear`, `/test`, `/previews`, `/previews/{*name}`, `.json`, `/latest.json`), `kernel::mailbox::router(PREVIEWS)` with `.layer(ui::no_store())`, the templates and `kernel/ui.rs` chrome. Converge: replace the module and templates with the spec (the table is the only state). Keep any existing `.eml`/raw URL as a redirect to `/message.eml` if anything links to it. Move previews: create `src/mailers/previews.rs` with `PREVIEWS` (port each existing preview fn), make `GET /dev/mailers` a 301 to `/dev/mailbox/previews`, and delete the old previews page and templates. 5. **Links into kernel pages.** Detect: `grep -rn 'href="/dev\|href="/admin' templates/ web/src/` (the footer "Dev tools" link, mailbox hints on `/session/new`, `/login-links/sent`, nav to `/admin/jobs`). Converge: add `hx-boost="false"` (htmx) or use a plain `` (TS modes); use `paths::dev_mailbox()`; add `app_links_into_kernel_pages_are_not_boosted`. 6. **Jobs kernel.** Detect: `src/kernel/jobs/` files, the `jobs` columns (compare with jobs.md, Tables), `job_queues`/`job_nodes`/`recurring_executions`. Converge: missing tables or columns via a new migration; then replace `kernel/jobs/` with `mod.rs`/`executor.rs`/`plugins.rs`/`admin.rs`/`web.rs` per the spec. Adapt `src/jobs/mod.rs` (`into_job` → `JobBuilder::new(JobDefaults{…}, serde_json::to_value(&self))`, `fn unique()`, `worker_config` → `WorkerConfig::default()` fallback), `recurring.rs` to `&[Recurring]`, and `main.rs`'s `WorkerOptions` and `app jobs …` CLI. Keep every existing `Worker::NAME`. 7. **`/admin/jobs`, `/dev`, pending migrations.** Detect: `kernel/jobs/web.rs`, `controllers/dev.rs` routes, `controllers/migrations.rs`. Converge to jobs-dashboard.md and dev-tools.md: `route_layer(AdminAccess)`, `no_store()` on each kernel router, the test buttons, `/dev/system` via `get_*_migrations_read_only`, the `migrations::guard` layer (development only), `/up` 503 while pending, the worker waiting for migrations, `bin/dev` migrating first. Add every new route to `ROUTES` (`Access::Admin` / `Access::Dev`). 8. **Assets and layout.** Add `admin_js` to `Assets`, `static/kernel/admin.js`, `historyCacheSize: 0` in `templates/kernel/layout.html`'s htmx-config, `templates/mailers/layout.html` (move an existing `templates/layouts/mailer.html` there and update `{% extends %}`). 9. **Config.** `APP_URL` defaults to `dev_app_url(PORT)` in development (parse `PORT` first); add `mailer` (`MAILER`) and `revision` (`APP_REVISION`) to `Config`, `.env.example` and the deploy config. 10. **Migration crate.** If `migration/Cargo.toml` uses default features: set `default-features = false` and delete `migration/src/main.rs` (check nothing runs `cargo run -p migration`). 11. **Transactions.** Detect: `grep -rn '\.begin()\.await' src/ | grep -v src/kernel/`. Converge: add `kernel::db::begin` (database.md: `begin_with_options` with `SqliteTransactionMode::Immediate`; Postgres ignores the mode) and replace every call site. Add the file-backed SQLite regression test (a write racing a read-then-write transaction). 12. **Append-only migrations.** Detect: `git log --diff-filter=M -- migration/src/m*.rs` (edits to migrations after their first commit). Any hit is *present-divergent*: restore the committed original and move the change into a new `alter` migration. Then add the CI guard step from testing.md (fails when a committed migration file is modified). 13. **Boosted errors.** Detect: `static/app.js` lacks an `htmx:responseError`/`htmx:beforeSwap` handler for 5xx; the layout's `htmx-config` lacks `responseHandling` for 422/429. Converge to frontend-htmx.md (both pieces), and check in a browser that a forced 500 on a boosted form shows the error page. 14. **Auth (only if `ACCOUNTS` isn't `none`).** Detect the variant actually built (password tables, `login_links`/`login_tokens`). Record it in `ACCOUNTS` (an improvised passwordless flow = `magic-link`). Converge to auth.md's variant: token digest only, single use, the TTL, GET → confirm page → POST consumes, the same response for known and unknown emails, rate limits per IP and per email, and the token minted **inside** its job. Add a mailer preview for every auth email, and the dev mailbox hint (`templates/shared/_dev_mailbox_hint.html`, development + `Mailer::Local` only) with its present-in-dev/absent-in-prod test. Admin Basic auth: add the failed-attempt limiter (security.md). 15. **SSE.** Detect: `Event::default().event(…)` without `.data(…)`, or per-resource stream routes. Converge to realtime.md: non-empty data, one `/streams/{stream}` route, `take_until(pubsub.closed())`, and the app layout's `historyCacheSize: 0` + `refreshOnHistoryMiss: true`. 16. **Errors and routing.** Converge `controllers::errors::not_found` to return the `x-route-fallback` marker, have `shape_errors` keep it, and tighten `every_route_is_routable_in_development` to use it. Render the 422 fallback with `templates/errors/_invalid.html`. Delete any `format!`-built HTML. 17. **Postgres test databases.** Detect: `test_` names in `tests/app/support.rs`. Converge to `test__`, the joined-thread `Drop`, and the scoped, idle-only `test_db_cleanup` (testing.md#database-per-test). 18. **Env files.** In `.env.example` (and the human's `.env`, if present and if they agree), comment out `APP_URL` for development; keep it required in production. 19. **CSS check.** Converge `kernel/assets.rs` to warn and show a banner only when `static/app.css` is missing or empty (never by timestamp: Tailwind 4 doesn't rewrite unchanged output), and to fail boot in production. 20. **Tests.** Bring `tests/app/kernel_jobs_test.rs` and `kernel_web_test.rs` to the lists in new.md step 7 (both backends), and `support.rs` to the job helpers + `wait_until`. Then the Definition of Done, and, if Node ≥ 22.12 and Chrome are present, `bash .offrails/tools/browser-smoke/run.sh`. ## 0.1.0 (2026-10-12) ### What's new - The framework docs: the conventions, guides and recipes an agent follows to build a Rust web app. - The Claude Code plugin: the `new`, `feature`, `scaffold`, `generate`, `check`, `doctor`, `deploy` and `upgrade` skills, and the convention hooks. - Ideas borrowed from Phoenix/Elixir: Oban-style persistent jobs with the `/admin/jobs` dashboard, a Swoosh-style `/dev/mailbox`, and a `/dev` toolbox. ### Breaking changes None (first release). ### Upgrading Nothing to upgrade: this is the baseline. ### Full list of changes First release: - The framework docs. - The Claude Code plugin: - skills: `new`, `feature`, `scaffold`, `generate`, `check`, `doctor`, `deploy`, `upgrade`; - the convention hooks. - Borrowed from Phoenix/Elixir: - Oban-style persistent jobs: a state machine, history, queues with pause/resume, unique jobs, snooze/cancel/discard, the Stager/Lifeline/Pruner/Cron helpers, and `Oban.Testing`-style helpers; - the `/admin/jobs` dashboard (Oban Web); - the Swoosh-style `/dev/mailbox`; - a `/dev` toolbox. **App migration:** none (baseline). ================================================================ FILE: CODE_OF_CONDUCT.md (https://rustoffrails.org/docs/code-of-conduct.md) ================================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at a private message to the maintainers through GitHub (the [rust-off-rails organization](https://github.com/rust-off-rails) owners), or a private report via **Security → Report a vulnerability** on this repository with "Code of Conduct" in the title. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations ================================================================ FILE: CONTRIBUTING.md (https://rustoffrails.org/docs/contributing.md) ================================================================ # Contributing Rust off Rails has one founding rule: **all code is written by AI** ([doctrine §0](doctrine.md#0-all-code-is-written-by-ai)). That applies to this repository too. Contributions are **intent and evidence**, not hand-written code. ## Code policy - **AI-written code is what we accept.** Docs, generators, skills, hooks and scripts in this repo are written by coding agents, steered by people. - **Hand-written code is the exception.** If you had to write something by hand, explain why in the PR (what the agents got wrong, which agent and model, what you tried). It may still be accepted, but the explanation is required, and we'll usually prefer turning it into a doc fix so agents get it right next time. - **The template is code the framework ships; parts come from the samples repo.** Since 0.7.1 the framework ships the app skeleton as real, tested files ([template/](template/README.md)) that agents copy and then own; the guides stay the spec for why. Agent-written changes to `template/app/` are welcome with the same evidence as a doc fix (`tools/ci/test-template.sh` green on both databases). The optional parts in `template/parts/` are the [rust-off-rails/samples](https://github.com/rust-off-rails/samples), synced verbatim by `tools/sync-parts.sh`: changes to a part go to that repo. A guide rule a sample exposed as wrong is a doc fix here. ## Great contributions - **A doc bug found by building something.** You followed the framework, an agent got stuck, improvised or produced something wrong. Open an issue with the app type, the step, what happened, and (ideally) the agent transcript excerpt. This is the most valuable thing you can send. - **A verified doc fix.** Have your agent fix the guide, *prove it* by building or changing a real app from the corrected docs, and open a PR that includes: - what you built and with which agent; - the Definition of Done results (both databases); - the crate and package versions it was verified against; - a `CHANGELOG.md` entry under `## Unreleased` with reconcile-style **App migration** steps if existing apps are affected. - **New eval cases** in `evals/` that catch agents doing the wrong thing. - **Ideas** for features (as specs: the problem, who it's for, what Rails/Phoenix/others do). ## House rules - Every code snippet in the docs must match code that compiled. Don't guess APIs; check the resolved version. - Keep the voice: terse, opinionated, written for agents. - Never weaken a security rule without a security review in the PR. - Run before opening a PR: `claude plugin validate . --strict`, and make sure every Markdown link resolves (CI checks both). ## Security issues See [SECURITY.md](SECURITY.md). Please don't report vulnerabilities in public issues. ================================================================ FILE: SECURITY.md (https://rustoffrails.org/docs/security.md) ================================================================ # Security policy Security is Rust off Rails' gold standard. The framework tells agents to build apps with strict CSP and security headers, authorization on every route and socket join, hashed tokens, rate limits and attack tests ([guides/security.md](guides/security.md)). A flaw in that guidance spreads to every app generated from it, so we treat **doc vulnerabilities as real vulnerabilities**. ## Reporting a vulnerability **Please don't open a public issue.** Report privately through GitHub: **Security → Report a vulnerability** on this repository ([private vulnerability reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)). Useful reports include: - the guide, generator, skill or hook involved (file and section); - what an app built from it is exposed to (a concrete exploit scenario); - the fix you'd suggest, if any. We aim to acknowledge reports within 3 business days and to ship a fix (with a `CHANGELOG.md` entry and upgrade steps for existing apps) as fast as the severity demands. ## In scope - Guidance in `guides/`, `generators/`, `blueprints/` and `stack.md` that leads agents to write vulnerable code. - The Claude Code plugin: `skills/` and `hooks/` (e.g. a hook that can be made to run arbitrary commands, or a check that a pattern bypasses). - `tools/` scripts. ## Out of scope Vulnerabilities in a specific generated app caused by deviating from the framework (check its `docs/decisions.md`), and vulnerabilities in third-party crates and packages: report those upstream, then tell us if the framework should ban or pin something. ## Supported versions The latest release in `CHANGELOG.md`. Apps upgrade with `/offrails:upgrade`. ================================================================ FILE: guides/api.md (https://rustoffrails.org/docs/guides/api.md) ================================================================ # APIs: `FRONTEND_MODE: api` and the `API` axis > **TL;DR** > - `FRONTEND_MODE: api` means no UI at all; the `API` header (`none | rest | graphql | both`) is a separate choice, so any mode can expose a public API. > - REST lives under `/api/v1`, returns `{"data": …}` (lists add `page`), takes bodies through `ApiJson` (JSON 422s), and is documented by utoipa at `/api/openapi.json`, committed with a drift test. > - Auth is bearer API tokens: digest-only, scoped, expiring, rate-limited per token. > - CORS uses exact origins from `APP_CORS_ORIGINS`, never `*` and no credentials; those origins are also CSRF-trusted. > - Never return an entity `Model` or a `json!` blob, and never 403 another user's record (scope the query, 404). > > Read the rest when building an API-only app, adding tokens, CORS or OpenAPI, or writing API tests. Two separate choices, both made in the [interview](../interview.md) and recorded in the app's `AGENTS.md`: | Header | Values | Means | |--------|--------|-------| | `FRONTEND_MODE` | `htmx \| inertia \| spa \| api` | Who renders the UI. **`api`** = no UI at all, like `rails new --api`: no app templates, no Tailwind, no `web/` | | `API` | `none \| rest \| graphql \| both` | Whether the app exposes a **documented, versioned API to other programs** (mobile apps, partners, scripts), and in which style. Independent of the UI: an `htmx` app can have `API: rest` | Rules: - `FRONTEND_MODE: api` requires `API: rest | graphql | both`. Default: `rest`. Choose `graphql` when the clients are UIs with varied data needs (several screens, nested data), `both` when the spec names both kinds of client. - `FRONTEND_MODE: spa` already has a JSON API for its own SPA under `/api`. That's an internal API (cookie sessions, types via ts-rs, no versioning promise). `API: rest` on top of it means *public*: `/api/v1`, bearer tokens, OpenAPI, frozen contracts ([test-standards](test-standards.md#2-the-required-test-set-by-archetype)). - `API: none` is the default for every UI mode. Every app, `api` mode included, keeps the kernel UIs: `/dev` (mailbox, routes, system), `/admin/jobs`. They're self-contained pages, not app UI. Reference implementation: the `offrails-api-demo` app (`FRONTEND_MODE: api`, `API: both`), built from these docs and green on SQLite and Postgres. Its layout is what this guide describes. ## Layout (`api` mode) ``` src/controllers/ api/mod.rs ApiJson extractor, ApiDoc (OpenAPI), ErrorBody/InvalidBody, docs() dev page api/v1/.rs REST handlers, one file per resource, annotated with #[utoipa::path] graphql.rs POST /graphql + GraphiQL (dev) root.rs GET / (JSON index), GET /robots.txt (Disallow: /) application.rs ApiUser, OptionalApiUser (bearer tokens), AdminAccess src/graphql/ schema, query.rs, mutation.rs, types.rs, loaders.rs (guides/graphql.md) src/views/.rs #[derive(Serialize, ToSchema)] JSON bodies: NoteJson, NotesPage, NoteBody docs/openapi.json committed; a test fails when it drifts docs/schema.graphql committed; a test fails when it drifts ``` No `templates/` except `templates/kernel/`, `templates/mailers/` and the standalone `templates/errors/` pages the kernel UIs use. No `assets/`, no `bin/setup-tailwind`. ## Responses - **Errors** are always JSON (the `ResponseFlavor` is `Api` for every path except `/dev` and `/admin`): - `{"error": "not_found" | "unauthorized" | "forbidden" | "too_many_requests" | "payload_too_large" | "internal"}`; - validation: `422 {"errors": {"title": ["can't be blank"]}}`, with `base` for errors not tied to a field. - `401` carries `WWW-Authenticate: Bearer realm="api"`; `429` carries `Retry-After`. - **Bodies**: single resources are wrapped, `{"data": {...}}`, so fields can be added next to `data` later; lists are `{"data": [...], "page": {"page": 1, "per": 25, "has_more": true}}`. - **Pagination**: `?page=&per=` (`models::Page`, `per` clamped to 1..=100). Fetch `per + 1` rows to compute `has_more`; never `COUNT(*)` on the hot path. Order by `created_at DESC, id DESC`. - **IDs** are numbers in REST (`i64` fits JSON safely up to 2^53) and `ID` strings in GraphQL. - **Field names** are `snake_case`, the same as the database and Rust. No serde renames. - **Bodies are parsed by `ApiJson`**, never bare `Json`. Axum's own rejections are plain text (400/415/422); `ApiJson` maps them to `422 {"errors": {"body": ["is not valid JSON"]}}`. - **Entity models never cross the boundary.** Handlers return `views::` structs (`From`). That's how `password_digest` never leaks. ## Versioning - Everything public lives under `/api/v1`. A breaking change (removing or renaming a field, changing a type or a status code) means `/api/v2` next to `v1`, never an edit of `v1`. Additions (new fields, new endpoints) are not breaking. - The contract snapshots (insta) and the committed `docs/openapi.json` are what reviewers see. Changing them is a decision: log it in `docs/decisions.md` and tell the human. - Unversioned and kernel paths: `/`, `/up`, `/robots.txt`, `/api/openapi.json`, `/graphql` (GraphQL evolves by adding fields and `@deprecated`, not by URL). ## Authentication: API tokens Bearer tokens, stored like sessions: random 32 bytes, base64url, **only the SHA-256 digest in the database** (`kernel::session::{generate_token, digest}`). ``` api_tokens: id big_integer pk auto user_id big_integer → users.id ON DELETE CASCADE (indexed) name string // "CLI on my laptop" token_digest string UNIQUE scopes json // ["read","write"] last_used_at timestamptz NULL // written at most once a minute per token expires_at timestamptz NOT NULL // default now() + 90 days at mint; `--expires never` is an operator-only flag, logged in decisions created_at timestamptz ``` | Route | What | |-------|------| | `POST /api/v1/users` | Sign-up (`ACCOUNTS: password`, open sign-up): creates the user **and the first token in one transaction**, enqueues the welcome email, returns `201 {"token": "…", "data": TokenJson, "user": UserJson}` | | `POST /api/v1/tokens` | Email + password (+ optional `name`, `scopes`, `expires_in_days` 1..=365) → a new token. Same 422 (`base: "Invalid email or password"`) for an unknown email and a wrong password; one argon2 verify either way | | `GET /api/v1/tokens` | The caller's tokens, metadata only | | `DELETE /api/v1/tokens/current` | Log out: revoke the token making the request | A password reset or change deletes the user's tokens along with their sessions ([auth](auth.md#password-reset-passwordsrs-new-create-edit-update)); `app task tokens revoke-all ` does it from the terminal. | `GET /api/v1/me` | The caller | | `app task tokens create [name] \| list \| revoke ` | The same from the terminal, for operators and CI | The raw token is shown exactly once, in the `201` body or the task's output. It's never logged, never listed, never put in job args. Extractors (`controllers/application.rs`): - `ApiUser { user, token, scopes }`: missing, unknown, revoked or expired token → `Unauthorized`. Looked up **once per request** and cached in the extensions (one query: token join user), then rate-limited per token and `touch`ed. - `OptionalApiUser(Option)`: for public endpoints and the GraphQL context. - `me.require(Scope::Write)?` → `Forbidden` (403) when the token lacks the scope. Policies take the scopes too: `policies::note::can_edit(&me.user, &me.scopes, ¬e)`. Scopes are `read` and `write` by default. Add more only when a spec names them (e.g. `admin`), and never a wildcard. **Cookie sessions** stay available for apps with a UI (`htmx`/`inertia`/`spa`) that also expose `API`: browsers use the session cookie, other programs use tokens. The two extractors are separate; a handler accepts the one its route is for. `api` mode has no cookie sessions. ## Rate limits - Per token: 300 requests per minute across REST and GraphQL (`TOKEN_RATE_LIMIT`), in `load_api_user`. - Per IP: sign-up and token creation, 10 per 3 minutes (like the login form). - Same limiter, same rules ([security](security.md#rate-limiting-kernelrate_limitrs)): the window is fixed from the first request, refused requests don't extend it, and development allows 1000 per window. - Limits are per process (moka), like every rate limit here ([security](security.md)). Behind several web processes, divide or move the counter to the cache table. ## CORS Only browser apps on **other origins** need CORS (a separate SPA, a partner's site). Same-origin pages and non-browser clients never do. - `APP_CORS_ORIGINS=https://app.example,http://localhost:5173`: exact origins, comma-separated. `*` or anything not `http(s)://…` fails at boot (`config::parse_cors_origins`). Unset: no CORS headers at all. - `tower_http::cors::CorsLayer` with `AllowOrigin::list`, methods `GET POST PATCH DELETE`, headers `authorization content-type`, `max_age` 600s, **no credentials** (bearer tokens, not cookies). - The same origins are added to the CSRF layer's trusted origins (`CsrfLayer::add_trusted_origin`). Otherwise the allowed SPA's cross-site `POST` is refused by CSRF. Non-browser clients send neither `Origin` nor `Sec-Fetch-Site` and pass CSRF as-is. - Layer order: CORS outside CSRF, so preflights are answered before CSRF runs. ## OpenAPI (`API: rest | both`) `utoipa` generates an OpenAPI 3.1 document from the code: `#[utoipa::path(...)]` on each handler, `#[derive(ToSchema)]` on bodies and `Params`, `#[derive(IntoParams)]` on `Page`. One `#[derive(OpenApi)] struct ApiDoc` in `controllers/api/mod.rs` lists every path, plus a `Modify` that adds the `bearer` security scheme. ```rust #[utoipa::path( post, path = "/api/v1/notes", tag = "notes", request_body = NoteParams, security(("bearer" = [])), responses((status = 201, body = NoteBody), (status = 422, body = InvalidBody), (status = 403, body = ErrorBody)) )] pub async fn create(State(s): State, me: ApiUser, ApiJson(params): ApiJson) -> Result<(StatusCode, Json), AppError> { … } ``` - Served at `GET /api/openapi.json` (public; it's documentation). - **Committed at `docs/openapi.json`.** A test compares the served document with the file and fails when it drifts; it also asserts every `/api/v1` row of `ROUTES` is in the document. Regenerate after an intended change: `UPDATE_SCHEMAS=1 cargo nextest run openapi`. - **Dev reference UI** at `/dev/api-docs` (development only): a hand-written page that loads Scalar from jsDelivr at a **pinned** version (`SCALAR_VERSION`) with no inline script, and its own CSP (`script-src 'self' https://cdn.jsdelivr.net`, `style-src … 'unsafe-inline'`). Don't add `utoipa-scalar`/`utoipa-swagger-ui`: the first loads an **unpinned** CDN script, the second downloads Swagger UI from GitHub at build time (breaks offline and Docker builds). - utoipa fills `info.license` from Cargo metadata with an empty name, which is invalid OpenAPI. The `Modify` sets it to `None`. ## Testing `TestApp` helpers for APIs: `login_as(&user) -> token` (a real read+write token, sent from then on), `login_with_scopes(&user, &["read"])`, `use_token(Some(..)/None)`, `post_json`, `patch_json`, `delete`, `json(method, path, body)`, `graphql(query, variables)`, and `TestResponse::json()`. Required for `API: rest`: - per endpoint: the request matrix (happy path, 422 with exact body, 401 without a token, 404 for another user's record, 403 for a missing scope); - `security_test.rs`: missing/garbage/revoked/expired tokens are all the same 401; a read-only token can't write anything; the per-token rate limit; CORS allows only the allowlist (preflight and real request) and never sends credentials; foreign `Origin` still fails CSRF; JSON bodies ignore ownership fields; every response is `application/json` with `nosniff`; - `api_contract_test.rs`: insta snapshots of each body shape (redact ids and timestamps), and the OpenAPI drift test; - the list endpoint's query count is stable (warm up first: the first request with a token writes `last_used_at`). ## Gotchas agents get wrong - Using `Json` for request bodies: clients get plain-text 400s. Use `ApiJson`. - Returning the entity `Model` (leaks columns) or building JSON with `json!` in handlers (no schema, no snapshot). Use a `views` struct. - `403` for someone else's record: it leaks existence. Scope the query to the owner and return 404. - Adding CORS origins but not CSRF trusted origins (the SPA's POSTs get 403), or `allow_credentials(true)` with bearer tokens (pointless, and widens the attack surface). - Forgetting `UPDATE_SCHEMAS=1` after an intended change, then "fixing" the drift test by deleting it. - A query-count test without the warm-up request: the first request touches `last_used_at`, so counts differ by one. ================================================================ FILE: guides/auth.md (https://rustoffrails.org/docs/guides/auth.md) ================================================================ # Authentication & Authorization > **TL;DR** > - `ACCOUNTS` picks the variant: the sign-in methods joined by `+` (`password`, `magic-link`, `password+magic-link`), then `invite` when sign-up is closed (`password invite`). Invite-only is one switch (`DEFAULT_SIGNUP` / `APP_AUTH_SIGNUP`). Generated by `generators/authentication.md`. > - Tokens and sessions are random, stored as SHA-256 digests only, compared after hashing; session cookie HttpOnly + SameSite=Lax, Secure and `__Host-` in production. > - Magic links: GET shows a confirm page, POST consumes (scanners can't burn it); single use, 15 min; the token is minted inside the job; identical responses for known and unknown emails; rate limits per IP and per email. > - Authorization: `RequireUser` on every protected handler plus a policy or an owner-scoped query; another user's record is 404. > - Logout, password change and reset revoke sessions, including open sockets and streams; dev screens show the mailbox hint. > > Read the rest when adding accounts, changing login, writing policies, or doing multi-tenancy. **Use the part.** The `auth-core`, `auth-password`, `auth-magic-link` and `auth-totp` [parts](samples.md#the-samples) are this guide built, tested on SQLite and Postgres and security-reviewed, with more on top: an account security page (devices, activity), re-auth, email change, security emails with a "this wasn't me" link, an audit trail, email verification, a 6-digit code beside the magic link, and TOTP two-factor with recovery codes. They bring their own schema: in an app that already has accounts, adopting them is a data migration. When the product needs this, add the part rather than writing it from this guide: `cargo offrails add-part auth-magic-link` or `auth-password` (the method matching `ACCOUNTS`; each brings `auth-core`, `mailer` and `dev-mailbox`), plus `auth-totp` for two-factor (dependencies come first; [workflow](../workflow.md#when-the-product-grows-a-need-add-the-part)). Keep its `// SECURITY:` markers and the tests its CHECKS.md cites. This guide stays the spec: hand-write from it only when no part fits, or the human declined the part. The equivalent of Rails 8's `rails generate authentication` (and Phoenix's `phx.gen.auth`), plus the authorization layer Rails leaves to Pundit. Installed as parts (`cargo offrails add auth-core auth-password`, `auth-magic-link`, `auth-totp`; invite-only is a switch), with [`generators/authentication.md`](../generators/authentication.md) as the order of work and what they leave to you. This guide is the spec. > Verified on 2026-10-12 by building the reference app (htmx, `ACCOUNTS: signup`) against argon2 0.6, sha2 0.11, rand 0.10, axum 0.8.9 and SeaORM 2.0.4, with green tests on SQLite and Postgres 18. The `magic-link` variant was verified on 2026-10-13 by a fresh agent building a separate app (Linkdrop) from these docs alone: 168 tests on SQLite and Postgres 18, and the full flow in a real browser and through the mailbox JSON (scanner-safe confirm page, single use, expiry, enumeration-safe responses, per-IP and per-email limits, no token in job args). ## Variants (`ACCOUNTS` in the app's `AGENTS.md`) `ACCOUNTS` is the sign-in methods joined by `+`, then `invite` when sign-up is closed: `ACCOUNTS: password`, `magic-link`, `password+magic-link`, `magic-link invite`, `password+totp invite`. The words before 0.7.3 map onto it: `signup` = `password`, `invite` = `password invite`, `magic-link-invite` = `magic-link invite`. | `ACCOUNTS` | Login | New accounts | Email needed | |------------|-------|--------------|--------------| | `magic-link` | One-time link (or 6-digit code) by email; no passwords | Created on first login (public sign-up) | Yes (dev mailbox locally; a provider before launch) | | `magic-link invite` | Same | Only invited people (`app task auth_invite `); unknown emails get nothing | Yes | | `password` | Email + password | Public sign-up form | For verification and reset | | `password invite` | Email + password | Only invited people: the invitation sets the password | For invites and resets | | `password+magic-link` | Either | Public sign-up (`… invite`: invited only) | Yes | | `none` | — | — | — | Open or invite-only is **one switch** for every method, not code to write: `DEFAULT_SIGNUP` in `src/config.rs` (`Signup::Open` or `Signup::Invite`; `cargo offrails new … --invite` sets it), overridden per deploy by `APP_AUTH_SIGNUP=open|invite`. Invite-only, the sign-up pages answer 404 and their links are hidden (`ctx.signup_open`), a magic link goes only to an existing account and never creates one, and people get in with `app task auth_invite ` (or `auth_invite::invite` from an admin screen). Set it in `src/config.rs`, not only in a deploy's env, so a forgotten variable can't open sign-up. The variants share sessions, extractors, return-to, rate limiting and the dev mailbox hint. They differ only in how a session starts and who may start the first one. ## Tables ``` users id, email (lowercased, unique users_email_key), password_digest, email_verified_at?, role?, created_at, updated_at sessions id, user_id → users (cascade, indexed), token_digest (unique sessions_token_digest_key), ip?, user_agent?, created_at, last_seen_at password_resets id, user_id → users (cascade, indexed), token_digest (unique), expires_at, created_at (password variants) login_links id, email, token_digest (unique), expires_at, created_at (magic-link variants) ``` - `password_digest` is `NOT NULL` in the password variants and **nullable** in the magic-link variants (where it's only set if the reader later chooses "also set a password"). - `login_links` stores the **email, not a user id**, so the same flow serves existing readers and first-time sign-ups. - `email_verified_at` is `NULL` until something sent to the address is used ([claiming an unverified account](#claiming-an-unverified-account-pre-hijacking)). - `role` exists only if the interview chose roles (`DeriveActiveEnum`: `admin`, `member`). - Multi-tenant apps add `accounts` and `memberships` (see below). - `CleanupJob` deletes sessions idle for more than 30 days, resets older than 1 day, and expired login links. There's no separate recurring job. ## Tokens (`kernel/session.rs`) One token recipe for sessions, resets and login links: ```rust pub fn generate_token() -> String { URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()) } // 43 chars pub fn digest(token: &str) -> String { Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect() } ``` - Only `digest(token)` is stored. A database leak doesn't leak sessions or links. - Look rows up by digest (`Column::TokenDigest.eq(digest(&raw))`), never by the raw token. - sha2 0.11's output doesn't implement `LowerHex`, so hex-encode it byte by byte as shown. ## Passwords (password variants) - **argon2 0.6** (`cargo add argon2`; the defaults `alloc`, `getrandom` and `password-hash` are all you need). 0.6 generates the salt itself: ```rust use argon2::{Argon2, password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash}}; let digest = Argon2::default().hash_password(password.as_bytes())?.to_string(); // "$argon2id$v=19$…" let ok = Argon2::default().verify_password(password.as_bytes(), &PasswordHash::new(&digest)?).is_ok(); ``` (0.5's `hash_password(pw, &salt)` with `SaltString::generate(&mut OsRng)` no longer compiles.) - Both calls are CPU-heavy, so wrap them in `tokio::task::spawn_blocking` (`user::hash_password`, `user::verify_password`). A malformed digest is a mismatch, not an error. - Validation: 12–72 characters, counted with `chars()`. Nothing else (no composition rules). Messages: `can't be blank`, `is too short (minimum is 12 characters)`, `is too long (maximum is 72 characters)`. - `user::Model` doesn't derive `Serialize`, so it can't leak `password_digest` into JSON or props by accident. ## Sessions Database-backed, as in Rails 8: 1. **Start** (`session::start(db, user_id, ip, user_agent) -> (Model, raw_token)`): insert the digest, then set the cookie `session_token=; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000` (30 days). In production (`secure = config.is_production()`) the name is **`__Host-session_token`** and `; Secure` is added: the `__Host-` prefix stops a sibling subdomain from planting a session ([security](security.md#the-header-matrix)). Format this header by hand in `kernel/session.rs` (`name(secure)`, `cookie(token, secure)`, `clear_cookie(secure)` with `Max-Age=0`). `read(headers, secure)` accepts **only** `name(secure)`, so production ignores a plain `session_token` cookie. Tests that set the cookie use `ksession::name(config.is_production())`. axum-extra doesn't re-export `cookie::time`, so `Cookie::max_age` would need an extra crate. 2. **Every request:** `load_session` (private, in `controllers/application.rs`) reads the cookie and runs `session::find_by_token(db, token, now)`. That's one query: `find_also_related(user::Entity)` filtered on `last_seen_at > now − 30 days`. The result, `None` included, is cached in the request extensions (`LoadedSession`), so `Ctx`, `CurrentUser`, `RequireUser` and `CurrentSession` in one handler cost a single query. `session::touch` bumps `last_seen_at` at most once an hour. 3. **Logout:** delete the session row (`CurrentSession` extractor) and clear the cookie. Replaying the old cookie must not log anyone in. There's a test for that. 4. **Log out everywhere** (password reset or change): `session::destroy_all_for_user`. 5. **Signing in over a session** (any method: password, magic link, a reset; a shared computer where Ann signs in while Bob's session is still in the browser): the handler takes `Option` and ends that session with `session::destroy` **in the sign-in transaction**, before starting the new one, never by just overwriting the cookie. `destroy` writes the session-ended signal, so the previous person's open sockets and streams close at once ([channels](channels.md#security)), not at their next revalidation, and the old token is dead even if it was copied. Test: sign in as A, open a stream (or socket) as A, sign in as B in the same browser → A's connection gets `bye` `revoked`, and replaying A's old cookie is logged out. Build responses that set cookies with `AppendHeaders([(SET_COOKIE, …), (SET_COOKIE, …)])` before the `Flash` and the `Redirect`. A plain header array would replace the earlier `Set-Cookie` instead of appending. ## Extractors (in `controllers/application.rs`) | Extractor | Behavior | |-----------|----------| | `Ctx` | Layout context, including `current_user: Option` and `dev_mailbox: bool` (below). Rejection: `AppError` (DB errors only) | | `CurrentUser(Option)` | Never rejects (DB errors aside) | | `RequireUser(User)` | `AppError::Unauthorized`, which `error.rs` maps per flavor: htmx → `HX-Redirect: /session/new`; HTML/inertia → 303 to `/session/new`; `/api` → 401 JSON | | `CurrentSession(session::Model)` | For logout. `Unauthorized` if logged out | | `UserAgent(Option)` | For the sessions table | | `RequireAdmin(User)` | Only if roles exist: `Unauthorized` if logged out, `Forbidden` if not admin. `AdminAccess` uses it | | `ClientIp` | From `kernel/rate_limit.rs`. `None` in `oneshot` tests | ### Return-to (`kernel/return_to.rs`) - **Store:** `error::shape_errors` sees `Unauthorized` on a **GET** that isn't under `/api`. It appends `_return_to=; Path=/; HttpOnly; SameSite=Lax; Max-Age=600` to the redirect. The value is base64url because paths can contain `;` and `,`. - **Use:** after a successful login, `return_to::read(&headers)` gives the path. Redirect there (or to `/`), and clear the cookie in the same response, so it's used once. - **Validate:** only same-origin relative paths are allowed. A path must start with `/`, and must not start with `//` or `/\` (browsers treat `\` as `/`) or contain control characters. Anything else is ignored. ## Password variants (`password`, `password invite`) ### Login (`sessions.rs`: `new`, `create`, `destroy`) - Normalize the email with `email.trim().to_lowercase()` (the same function used at sign-up). - **No enumeration:** `user::authenticate(db, email, password)` always runs exactly one argon2 verify. If there's no such user, it verifies against a dummy digest computed once in a `LazyLock`. A failure re-renders the form with **422** and the alert "Try another email address or password.", keeping the email. - **Rate limit:** 10 attempts per 3 minutes per IP + action (`ip.key("sessions#create")`), the window fixed from the first attempt; **refused attempts don't count and never extend the lockout**, so a locked-out person gets in 3 minutes after their first attempt however often they retry ([security](security.md#rate-limiting-kernelrate_limitrs)). Development allows 1000 per window. Over the limit, re-render the form with **429** and the alert "Try again later.", so humans see why and tests can assert the status. `/api` gets plain 429 JSON. Even the right password is refused while limited. - A logged-in reader who opens `/session/new` or `/registration/new` is redirected to `/`. ### Sign-up (open sign-up only; `registrations.rs`: `new`, `create`) Validate `SignupParams { email, password }`. Then, in **one transaction**, create the user and the session, so there's never an account without a login or the reverse. A duplicate email is caught from the unique index (no pre-query). When the app sends email, don't say "Email has already been taken" (that enumerates accounts): answer exactly as for a new sign-up ("Check your email to confirm") and send the existing owner a "someone tried to sign up with your address" note instead. Without email, the 422 is unavoidable; say so in `docs/decisions.md`. On a failed submit, re-render the form without the password. Enqueue a welcome email only if `EMAIL` is configured. Invite-only apps (`password invite`) answer 404 here (the handlers take `SignupOpen`); people come in through `app task auth_invite `, whose single-use link sets the password and verifies the address. ### Password reset (`passwords.rs`: `new`, `create`, `edit`, `update`) 1. `POST /passwords {email}`: rate limited per IP + action like login, **and per address** (`format!("passwords#create:{email}")`, 10 per 3 minutes, plus the daily cap below), because each request emails someone who didn't ask. If the user exists, enqueue **`PasswordResetEmailJob { user_id }`**. **Always** redirect to `/session/new` with the same flash: "If that address exists, we've sent password reset instructions." 2. **The job creates the token** (`password_reset::issue(db, user_id, now)`, 15-minute expiry), renders `mailers::passwords::reset` and calls `state.mailer.deliver` directly. `MAX_ATTEMPTS = 5`; if the user no longer exists, it returns `Outcome::Cancel`. - Why not `deliver_later_until(&txn, email, expires_at)`? That would put the rendered email, reset link included, into job args, where anyone who can open `/admin/jobs` could read it and take over the account ([security](security.md#checklist): "job args carry no secrets"). Issuing the token at send time also means the 15 minutes start when the email actually leaves. 3. The link is `{APP_URL}/passwords/{token}/edit`. `edit` checks `password_reset::find_valid(db, token, now)` (unexpired, by digest). An invalid or used link redirects to `/passwords/new` with "That password reset link is invalid or has expired." 4. `POST /passwords/{token}` (htmx) / `PATCH` (inertia): `password_reset::consume`. Validate the new password first (422 with the form; the link keeps working). Then, in one transaction: set the digest, delete **all** of that user's resets (single use), sessions (log out everywhere) **and API tokens** (`api_tokens` when the app has an API: a bearer token minted by whoever took the account over must die with the password, [api](api.md#authentication-api-tokens)). Finally start a fresh session and redirect to `/`. The same three deletes run on a password **change** (`account.rs`). ## Magic-link variants (`magic-link`, `magic-link invite`) Phoenix 1.8's default. There are no passwords to leak, reuse or reset. Every rule from the password variants about tokens, sessions, rate limits and enumeration still applies. ### Flow | Step | `htmx` | `inertia` | `spa` | |------|--------|-----------|---------| | Email form | `GET /session/new` (email only) | same | (SPA route) | | Request a link | `POST /login_links {email}` → 303 `/login_links/sent` | same | `POST /api/login_links` → 202 | | "Check your email" | `GET /login_links/sent` | same | (SPA route) | | Confirm page | `GET /login_links/{token}` | same | (SPA route reading the token) | | Log in | `POST /login_links/{token}` | same | `POST /api/login_links/{token}` | | Log out | `POST /session/delete` | `DELETE /session` | `DELETE /api/session` | 1. **Request** (`login_links::create`): - Normalize the email, then apply the rate limits: per IP + action, and per email (`format!("login_links#create:{email}")`), each 10 per 3 minutes; plus a **daily cap per address**, 20 emails a day (`format!("emails/day:{email}")`, `Duration::from_secs(86_400)`), shared with password reset requests. The per-IP limit doesn't stop a flood from many IPs, and 10 per 3 minutes alone is still 4,800 emails a day to one inbox. Over any of these, send nothing and re-render the form with **429** and "Try again later." like login (with `Retry-After`). That reveals nothing about the address: any address, registered or not, hits the same limits. - If the reader may log in, enqueue **`LoginLinkEmailJob { email }`**. Under `magic-link` that's every syntactically valid email (sign-up happens on first login); under `magic-link invite`, only an existing user. - **Always** redirect to `/login_links/sent`, with no hint about whether the address exists. Invalid email syntax re-renders the form with 422 "Email is invalid". That's a format error, not enumeration. 2. **The job** issues the link: a `login_links` row (email, digest, `expires_at = now + 15 min`). It renders `mailers::login_links::login` with `{APP_URL}/login_links/{token}` and delivers it directly. `MAX_ATTEMPTS = 5`. The token never sits in job args; the email does, and that's personal data, not a secret. 3. **Confirm page** (`GET /login_links/{token}`): if the token is valid and unexpired, render "Log in as ann@example.com" with one button that POSTs. **GET never consumes the token.** Email security scanners and link previews prefetch URLs, and a GET that logged in would burn the link before the human clicks it. An invalid link redirects to `/session/new` with "That login link is invalid or has expired." 4. **Consume** (`POST /login_links/{token}`): `login_link::consume(db, raw, now) -> Option<(User, Session, raw_session_token)>`, **one transaction** that: - deletes the row (single use; a second POST finds nothing); - finds the user by email, or **creates it** under `magic-link` (no password digest) with `INSERT … ON CONFLICT (email) DO NOTHING` then a select (two first logins racing can't fail, and inside a Postgres transaction a unique violation would abort the transaction, see [models](models.md#validations)); - starts the session (inserts the session row, returns the raw cookie token). - in an app that also has passwords, claims an unverified account first ([below](#claiming-an-unverified-account-pre-hijacking)). Then set the cookie, honor return-to, and redirect. If the reader was logged in as someone else, end that session in the same transaction with `session::destroy` (Sessions, item 5): take `Option` in the handler, which in axum 0.8 needs `impl OptionalFromRequestParts for CurrentSession` (a plain `FromRequestParts` impl no longer gives you `Option`). ### Claiming an unverified account (pre-hijacking) An account isn't proven to belong to its address until something sent to that address is used. With open password sign-up (`password`), anyone can register `victim@example.com` with a password they know, before the victim does, and set things up on it (an API token, a webhook, a notification address), hoping the owner later gets in another way (a reset link, or a magic link in an app that offers both) and starts using an account the attacker still holds. The first proof of the address **claims** the account and drops everything set up before it: - `users.email_verified_at` (nullable `timestamp_with_time_zone`). Sign-up leaves it `NULL`; a magic-link first login creates the user verified (the link was the proof); an invite sets it when the invite is accepted. - `user::claim_unverified(txn, user_id, now) -> Result`: `UPDATE users SET email_verified_at = now WHERE id = ? AND email_verified_at IS NULL`, `true` when it updated the row. The UPDATE locks the row, so a racing request either committed before (and its work is dropped below) or waits and sees the account verified. - Call it **in the transaction of every proof**: consuming a password reset, consuming a login link (an app with both methods), accepting an invite. When it returns `true`, in that transaction: delete every session (`destroy_all_for_user`, which signals open connections), every API token, every pending reset and login link of that user, and anything else that grants access or redirects mail (webhooks, forwarding addresses) the app has; on a **link** sign-in also set `password_digest = NULL`, because whoever registered chose that password. Then continue (set the new password, start the session). - Tests: `an_api_token_made_before_the_first_reset_is_revoked` (`password` with an API), `a_password_set_before_verification_dies_when_the_owner_signs_in_by_link` (an app with both methods), and that a second reset on a verified account keeps the user's API tokens. Variants with nothing to claim are n/a: `magic-link` and `magic-link invite` without passwords (the first login is the proof), and `password invite` where the invitation link is the only way in. **Token URLs leak through history, caches and referrers.** The confirm page (`GET /login_links/{token}`, and `GET /passwords/{token}` in the password variants) responds with `Cache-Control: no-store` and `Referrer-Policy: no-referrer`, so the token isn't cached, and isn't sent to any third party the page links to. 5. **Optional password later:** a "Set a password" page under the account settings lets a reader add a password. Password login then works the same as in `password`, with `authenticate` treating a `NULL` digest as a mismatch after the dummy verify. Only add this if a spec asks for it. ### Required tests On top of [test-standards](test-standards.md): - **First login can't abort its transaction (Postgres):** call `insert_if_absent(&txn, email)` twice in one transaction, then keep using the transaction (e.g. create the session). The race itself isn't testable; this is, and it fails on Postgres with the catch-the-violation version. - A known email and an unknown one get byte-identical responses (status, location, flash). Mail: under `magic-link` **both** get a login email (sign-up happens on first login); under `magic-link invite`, only the known one (`drain_jobs`, then `sent_emails`). - `GET` on the link twice, then `POST`: still logs in (scanner-safe). A second `POST` → invalid. - Expired (issue with `now − 16 min`) → invalid. 14 minutes → valid. - Refused attempts don't extend the window: exhaust the limit with a 1 s window (`state.rate_limit.check(&key, 10, Duration::from_secs(1))` directly, kernel test), keep retrying, and the first check after the window passes succeeds (`wait_until`, no fixed sleep). - Rate limit by IP, and by email **across different IPs**: build the test app with `APP_TRUSTED_PROXY=true` and vary `X-Forwarded-For` per request, so each request has its own IP key while the email key is shared ([security](security.md#rate-limiting-kernelrate_limitrs)). - First login under `magic-link` creates exactly one user. Under `magic-link invite`, an unknown email creates nothing and gets no mail. - Return-to is honored after the POST. - `/admin/jobs` args for `LoginLinkEmailJob` contain no `/login_links/` URL. - The daily cap: the 21st request for one address in a day sends nothing and gets the 429 form, for a registered and an unregistered address alike (`link_requests_are_capped_per_address_per_day`; build the requests from different IPs with `APP_TRUSTED_PROXY=true`, and use a rate limiter built for `AppEnv::Test`, since development allows 1000 per window). - Signing in by link over another reader's session ends it (sessions, item 5). - The dev mailbox hint is on `/login_links/sent` (development) and absent in production config. ## Dev mailbox hint (all variants) Phoenix's "You are running the local mail adapter" notice. In development, every auth screen that sends or talks about an email shows a notice linking to the [dev mailbox](mailers.md): > You're running locally, so emails aren't sent — they land in your local mailbox. **Open the mailbox →** - **Condition:** `config.is_development() && state.mailer.is_local()`, computed once into `Ctx.dev_mailbox` (htmx), or a `dev_mailbox: bool` shared prop (inertia) / `GET /api/session` field (spa). Never true in production or in tests (memory mailer). - **htmx:** `templates/shared/_dev_mailbox_hint.html`, guarded by `{% if ctx.dev_mailbox %}`. The link has `hx-boost="false"` because the mailbox is a kernel page with its own stylesheet, and a boosted swap would render it unstyled. **inertia/spa:** a `DevMailboxHint` component fed by that flag. - **Where:** - log in; - sign up; - forgot password; - reset password; - the page the reset request lands on (the login page, so it's covered); - **mandatory** on `/login_links/sent` in the magic-link variants. - **Test:** present with development config, absent with production config and test config (`accounts_test::auth_screens_link_to_the_dev_mailbox_in_development_only` in the reference app). ## Routes (password variants) | Purpose | `htmx` | `inertia` | `spa` (`/api`) | |---------|--------|-----------|------------------| | Login form | `GET /session/new` | `GET /session/new` | (SPA route) | | Log in | `POST /session` | `POST /session` | `POST /api/session` | | Log out | `POST /session/delete` | `DELETE /session` | `DELETE /api/session` | | Current user | (in `Ctx`) | (shared prop) | `GET /api/session` | | Sign up (open sign-up) | `GET /registration/new`, `POST /registration` | same | `POST /api/registration` | | Forgot password | `GET /passwords/new`, `POST /passwords` | same | `POST /api/passwords` | | Reset | `GET /passwords/{token}/edit`, `POST /passwords/{token}` | `GET …/edit`, `PATCH /passwords/{token}` | `PATCH /api/passwords/{token}` | In `ROUTES`, every auth route is `Access::Public` except log-out (`Access::User`). ## API tokens (`API` set, or `FRONTEND_MODE: api`) Programs authenticate with bearer tokens, not cookies. The table, routes, extractors (`ApiUser`, `OptionalApiUser`), scopes and rate limits are specified in [api](api.md#authentication-api-tokens). How they combine with `ACCOUNTS`: - `password` (open sign-up) + `api` mode: `POST /api/v1/users` creates the user and the first token in one transaction (no session, no sign-up page). `POST /api/v1/tokens` is the API's "login". - `… invite` / `magic-link` + `api` mode: users get their first token from an operator (`app task tokens create `), or from a magic-link flow whose confirm step mints a token instead of a session. Spec it before building. - UI modes with `API`: keep the cookie session for the browser, and add a "API tokens" settings page (list, create with name and scopes, revoke) that shows a new token once. - Tokens use the same `kernel::session::{generate_token, digest}` as sessions: random 32 bytes, only the SHA-256 is stored, never logged, never put in job args. ## Authorization Authorization is plain functions in `src/policies/.rs`. No DSL, no crate. ```rust // src/policies/mod.rs pub fn authorize(allowed: bool) -> Result<(), AppError> { if allowed { Ok(()) } else { Err(AppError::Forbidden) } } // src/policies/books.rs pub fn can_view(_user: &User, _book: &Book) -> bool { true } // spec: every logged-in reader pub fn can_edit(user: &User, book: &Book) -> bool { book.owner_id == user.id } pub fn can_destroy(user: &User, book: &Book) -> bool { can_edit(user, book) } ``` - Call `policies::authorize(policies::books::can_edit(&user, &book))?` in **every** handler that reads private data or writes. Views and props get booleans (`can_edit: bool`) to show or hide buttons. Hiding a button is not authorization. - **Visible but not yours → 403. Not visible → 404.** When the actor may see a record but not change it (another reader's book), `authorize` gives 403. For private, owned data, **scope instead of check**: `post::find_for(&db, &user, id)` filters by owner in the query and returns `NotFound`, so another user's id doesn't reveal that the record exists. The raw `find` is `pub(in crate::models)`, so a handler can't compile the unscoped call ([models](models.md#scoped-lookups-tenancy)). - Nested resources authorize against the parent first (`reviews::create` loads the book and checks `books::can_view`). - Policies are pure and unit-tested with a truth table: every action × every actor (owner, other user, admin, other tenant). Run `cargo mutants` on `src/policies` with zero survivors ([test-standards](test-standards.md#5-coverage-and-test-strength)). A policy that is constant by spec (`can_view → true`) produces an *equivalent mutant*; list it in `.cargo/mutants.toml` with the reason. ## Multi-tenancy (only if the interview chose teams/workspaces) - Tables: `accounts (id, name, …)`, `memberships (account_id, user_id, role, unique(account_id, user_id))`. Every tenant-owned table gets `account_id NOT NULL` + an index. - URL: `/a/{account_id}/…` (or subdomain, only if the spec says so). - `CurrentAccount(Account, Membership)` extractor: resolves from the path, and `Forbidden` if the user has no membership. - **Every query on tenant data goes through an actor-scoped model fn** (`find_for(db, &user, id)`, `all_for`, `destroy_for`) that filters by the user's memberships. Raw `find`/`destroy` are model-private ([models](models.md#scoped-lookups-tenancy)). No exceptions, not even "admin" screens: an admin path is its own scoped fn (`all_for_admin`). - **Generated with accounts, for every `TENANCY`:** `tests/app/tenancy_test.rs` (every id route in `ROUTES` with another tenant's ids → 404 or its specced status) and `tests/app/guard_test.rs` (no unscoped lookups in handlers). Templates: [test-standards §4](test-standards.md#4-tenant-isolation-suite-every-app-with-accounts). `offrails check` fails without them. ## Gotchas agents get wrong - Comparing raw tokens to the DB. Hash first, always. - Putting a token (or an email that contains one) in job args. Issue the token inside the job. - Different error messages, or different timing, for "no such email" and "wrong password". One argon2 verify on both paths. - A magic link that logs in on `GET`. Scanners burn it; confirm with a `POST`. - Signing in over an existing session by overwriting the cookie. The old session row lives on (a copied token still works) and its sockets stay open. `session::destroy` it in the sign-in transaction. - Rate limiting emailed links per IP only. A flood from many IPs fills one inbox; limit per address too, with a daily cap. - Treating an open-sign-up (`password`) account as its address owner's before anything sent there was used. Claim it on the first proof and drop what was set up before (pre-hijacking). - Forgetting `Secure` in production, or setting it in development (where it breaks login over http). - Open redirects via return-to, including `/\evil.example`. - Running argon2 on the async executor thread. - Serializing `user::Model` into props or JSON and leaking `password_digest`. - A rate-limited form that returns a bare 429 page. Re-render the form with the alert. - Factories hashing a password per user. At production cost, argon2 dominates suite time. Hash `PASSWORD` once per test binary (`tokio::sync::OnceCell`) and insert users with that digest. `models::user`'s own tests cover the real hashing. ================================================================ FILE: guides/caching.md (https://rustoffrails.org/docs/guides/caching.md) ================================================================ # Caching (Solid Cache / `Rails.cache` → `kernel/cache.rs`) > **TL;DR** > - Don't cache until it's measured or the spec requires it; log every cache in `docs/decisions.md`. > - `state.cache.fetch(&key, ttl, || async {…})`: moka in memory (L1, only for TTL ≥ 60s) in front of the `cache_entries` table. > - Never cache HTML containing the flash, forms or the current user; never fill a cache inside a transaction that may roll back. > - Bound keys built from user input (hash them, short TTL). > > Read the rest when adding a cache, designing keys, or using HTTP caching. **Don't cache until it's measured or the spec requires it.** Most Off Rails pages are a few indexed queries. A cache is a second source of truth with invalidation bugs attached. When you add one, log the reason in `docs/decisions.md`. | Rails | Off Rails | |-------|-----------| | `Rails.cache.fetch(key, expires_in:) { … }` | `state.cache.fetch(&key, ttl, \|\| async { … }).await?` | | `Rails.cache.delete(key)` | `state.cache.delete(&key).await?` | | `delete_matched` | `state.cache.delete_prefix("posts/").await?` | | `cache_key_with_version` | `format!("posts/{}-{}", post.id, post.updated_at.timestamp_micros())` | | Solid Cache | `cache_entries` table (L2) + `moka` (L1) | | Fragment caching (`cache @post do`) | Cache the *data* the view needs, not HTML. Askama rendering is fast | | `fresh_when` / `stale?` | Optional ETag/Last-Modified helpers on show pages | ## Table ``` cache_entries: key string pk // ≤ 250 chars value text // serde_json expires_at timestamp_with_time_zone index cache_entries_expires_at_idx (expires_at) ``` `CleanupJob` deletes expired rows hourly. ## Kernel spec (`src/kernel/cache.rs`) ```rust #[derive(Clone)] pub struct Cache { l1: moka::future::Cache>, db: DatabaseConnection } impl Cache { pub fn new(db: DatabaseConnection) -> Self; // L1: 10_000 entries, 60s TTL pub async fn fetch(&self, key: &str, ttl: Duration, compute: F) -> Result where T: Serialize + DeserializeOwned, F: FnOnce() -> Fut, Fut: Future>; pub async fn read(&self, key: &str) -> Result, AppError>; pub async fn write(&self, key: &str, value: &T, ttl: Duration) -> Result<(), AppError>; pub async fn delete(&self, key: &str) -> Result<(), AppError>; pub async fn delete_prefix(&self, prefix: &str) -> Result<(), AppError>; pub async fn delete_expired(&self) -> Result; // CleanupJob } ``` - `fetch`: `read`, else compute, `write`, return. - `read`: L1 hit → return. L2 hit (not expired) → return, and copy into L1 **only if the entry lives ≥ 60s more**. Otherwise a short-TTL entry could outlive its TTL in L1. - `write`: upsert L2 with `insert(..).on_conflict(OnConflict::column(key).update_columns([value, expires_at]))`. Fill L1 only when `ttl >= 60s` (moka's per-cache TTL is 60s). - L1 staleness is bounded at 60s: other processes' deletes only reach L2. If a spec can't tolerate that, bypass L1 for that key (`fetch_l2_only`). - `delete_prefix` uses `LIKE 'prefix%'` on L2 with `%`, `_` and `\` escaped and `.like(LikeExpr::new(pattern).escape('\\'))` (sea-query 1.0). Without the escape, `books_` also deletes `booksX…`. On L1 it uses `invalidate_entries_if` (enable moka's `support_invalidation_closures()`). - `delete_expired() -> u64` deletes expired L2 rows. `CleanupJob` calls it hourly. - Deserialization failure (a struct changed shape) counts as a miss. Recompute and overwrite. ## Key design - **Version keys with `updated_at`**, as Rails does: `posts/42-1728740000123456`. Updating the row changes the key, so explicit invalidation is rarely needed. - For collections: `posts/index/{max_updated_at}-{count}`, computed by one cheap aggregate query. - Prefix every key with the resource (`posts/`, `users/`), so `delete_prefix` works. - **Never** put per-user data in a shared key without the user id in it (`dashboards/user:7/…`). ## HTTP caching (optional) For public, read-heavy show pages: compute an ETag from `(id, updated_at)`. If it matches `If-None-Match`, return 304. Put this in a small helper in `controllers/application.rs`, used per handler. Never use it on pages with per-user content or flash messages. Static assets: `kernel/assets.rs` sets `Cache-Control: public, max-age=31536000, immutable` on digested URLs (see the frontend guides). Don't set long cache headers anywhere else. ## Gotchas agents get wrong - Caching rendered HTML that contains the CSRF-relevant form, the flash, or the current user's name. - Caching inside a transaction and then rolling back, which leaves the cache ahead of the DB. Compute cache values from committed data only, outside transactions. - Unbounded keys from user input (search queries) filling the table. Cap them with a hash: `search/{sha256(q)[..16]}`, plus a short TTL. - Forgetting that tests share nothing. `TestApp` gets a fresh `Cache`, so don't write tests that depend on cache state between requests unless that's what they test. ## Testing Test the behavior the cache must preserve: after an update, the page shows the new value. That proves the versioned key or invalidation works. Kernel tests (`tests/app/kernel_test.rs`, both backends) cover hit, miss, expiry, `delete_prefix` with a `_` in the prefix, `delete_expired`, and a short-TTL entry never being served from L1 after it expired in L2. ================================================================ FILE: guides/channels.md (https://rustoffrails.org/docs/guides/channels.md) ================================================================ # Channels, Presence and the Hub (`kernel/hub.rs` + the Off Rails Wire protocol) > **TL;DR** > - Signals ([realtime](realtime.md)) stay the default; use channels only when the client talks back (chat, games) or you need presence. > - One connection per tab speaking `offrails.v1` (negotiated subprotocol, JSON frames with `t`), with WebSocket → SSE+POST → long-poll fallback; resume with durable `seq` cursors. > - Each room is an actor in `kernel/hub.rs`; typed `In`/`Out` enums; write to the DB first, then emit. > - Security: Origin required on the handshake, session checked at upgrade and re-validated, every join and push authorized, per-user caps, revocation closes the socket (4401). > - Multi-node is opt-in (`kernel/cluster.rs`, Postgres `NOTIFY`, rendezvous ownership). > > Read the rest when building chat, presence or collaborative features, or touching the wire protocol. **Use the part.** The `realtime` [parts](samples.md#the-samples) are this guide built, tested on SQLite and Postgres and security-reviewed, with more on top: a live room, `/dev/sockets`, signed socket tokens and presence across tabs and nodes, with auth-core accounts when that sample is installed. When the product needs this, add the part rather than writing it from this guide: `cargo offrails add-part realtime` (dependencies come first; [workflow](../workflow.md#when-the-product-grows-a-need-add-the-part)). Keep its `// SECURITY:` markers and the tests its CHECKS.md cites. This guide stays the spec: hand-write from it only when no part fits, or the human declined the part. [realtime.md](realtime.md) covers **signals**: "book 42's reviews changed, re-fetch". That's enough for live lists, counters and notifications, and it stays the default. Channels are for what signals can't do: **the client talks back** (chat, typing, games, collaborative tools) or the page needs **who's here** (presence). The design is inspired by Phoenix Channels (one multiplexed connection per tab, topics joined with authorization, a state-owning process per room, presence that's correct across nodes), but the wire protocol is our own: **offrails.v1**, typed end to end, resumable, with transport fallback. > Verified on 2026-10-12 in the reference app (htmx, "who's reading" avatars and a per-book discussion): axum 0.8.9 `ws`, tokio 1.53, sea-orm 2.0.4 with sqlx 0.9's `PgListener`, tokio-tungstenite 0.29 as the test client. 223 tests on SQLite and Postgres 18 (39 for realtime: protocol conformance, security, all three transports, a 3-node cluster with a node killed mid-conversation), two Playwright specs (a server restart with changes made while it was down; WebSocket → SSE → long-polling fallback), and a manual two-context Chrome session. | Rails / Phoenix | Off Rails | |-----------------|-----------| | Action Cable channel / Phoenix.Channel | `src/channels/_room.rs`: a typed `Room` impl, plus an arm in `AppRoom` and `authorize` | | Phoenix.Socket (one socket, many topics) | `GET /socket` (WebSocket, subprotocol `offrails.v1`), `kernel/channels.rs`; falls back to SSE + POST, then long-polling | | GenServer per room, `handle_in/3` | `kernel/hub.rs` room actor: one tokio task per topic, one command at a time | | Phoenix.PubSub | the hub's per-topic `broadcast` channel (in-process) + `NOTIFY` between nodes | | Phoenix.Presence | `Hub::track` / `untrack`; `presence` frames (`here`, then `joins`/`leaves`) | | Registry / Horde / libcluster | `kernel/cluster.rs`: `cluster_nodes` heartbeats + rendezvous hashing over Postgres | | phoenix.js | `static/channels.js` (htmx) / `web/src/lib/socket.ts` (TS modes, typed) | | LiveDashboard sockets page | `/dev/sockets`, with a live decoded frame log | ## When to use which | The spec says… | Use | |----------------|-----| | "X updates without refreshing" (lists, counts, statuses) | **SSE signal** ([realtime.md](realtime.md)). No client→server messages, cheapest, works through every proxy | | "see who's online / reading / typing" | **Channel** with presence; a page that already streams signals and only shows a count ("12 people · 15 tabs here"): [presence over SSE](#presence-over-sse-no-websocket) | | "chat", "comments appear as people type", "multiplayer", "live cursor" | **Channel** with a `Room` | | "notify me" (personal notices) | SSE signal on `user:{id}`, or a channel topic `user:{id}` if the page already has a socket | Never both for the same data on one page. A page may use SSE for one region and a channel for another (the reference book page does: reviews over SSE, discussion over a channel). ## How it fits together ``` browser tab ──WebSocket /socket (offrails.v1)──▶ kernel/channels.rs Session: auth, limits, protocol └──or SSE /socket/sse + POST ──────────▶ (same Session, other pipes) └──or long-poll /socket/poll ─────────▶ │ join / push ▼ kernel/hub.rs Hub (one per process, in AppState) ├─ topic "book:7": broadcast::Sender → every local connection on it ├─ presence {person → refs (node, conn)} └─ room actor (tokio task) owns AppRoom::Book(BookRoom) state │ durable writes first ▼ database (book_messages) ← init() rebuilds state; replay reads it (cluster only) kernel/cluster.rs: cluster_nodes heartbeat + LISTEN/NOTIFY "offrails_cluster" ``` **Memory first, database as truth.** A room's state is memory, so a join, a replay or a rate-limit check costs microseconds. Anything that must survive (messages) is written to the database **before** it's broadcast, so `Room::init` can rebuild the state after a crash, a restart, or a move to another node. Presence is never stored: clients re-announce it by rejoining. ## The wire protocol (offrails.v1) The client opens `new WebSocket("/socket", "offrails.v1")`: the subprotocol is required and negotiated. Every frame, both directions, is a **JSON object with a `t` (type) field**. Unknown fields are ignored (forward compatibility); an unknown `t`, non-JSON, or an array is a protocol error. **Client → server** | `t` | Fields | Meaning | |-----|--------|---------| | `join` | `ref`, `topic`, `since`? | Join a topic. `since` = the last `seq` this client saw on it: the server replays every durable event after it | | `leave` | `ref`, `topic` | Leave (presence updates for everyone else) | | `push` | `ref`, `topic`, `event`, `data` | Send an event to the topic's room. `event` + `data` must match the room's typed `In` enum | | `ping` | `ref` | Liveness check, sent only after 25s of silence (browsers can't see WebSocket pings) | **Server → client** | `t` | Fields | Meaning | |-----|--------|---------| | `ok` | `ref`, `data`; for a join also `topic`, `replay` (n), `gap` | Success. A join's `ok` is followed by exactly `replay` `event` frames, then a `presence` `here`. `gap: true` = more was missed than can be replayed: reload the view | | `err` | `ref`, `code`, `message` | Refused. `code` is stable (for programs), `message` is a sentence (for people) | | `event` | `topic`, `event`, `data`, `seq`? | A room event. Durable events carry `seq` (the resume cursor); ephemeral ones (typing) don't | | `presence` | `topic`, `here` **or** `joins` + `leaves` | `here` replaces the list (sent after a join); `joins`/`leaves` patch it. Keys are people (user ids), values minimal metadata | | `pong` | `ref` | Answer to `ping` | | `slow` | `topic` | This connection's queue for the topic is ¾ full: it's about to be dropped if it doesn't catch up | | `bye` | `code`, `reason` | Sent right before the server closes the connection, saying why (`restart`, `revoked`, `too_slow`, `rate_limited`, `protocol`, `limit`, `idle`) | `err` codes: `forbidden`, `unknown_topic`, `not_joined`, `already_joined`, `invalid` (validation, message = the errors), `rate_limited`, `bad_event` (not in the room's `In` type), `limit`, `read_only`, `unavailable` (room restarting or node unreachable; retry). Close codes (always preceded by `bye`): `1012` restart · `1013` too slow · `1001` idle · `1008` rate limited / too many connections · `1002` protocol (also: no `offrails.v1` subprotocol) · `1003` binary frame · **`4401` session revoked or expired** (don't reconnect). **Heartbeats are WebSocket control frames**, not app messages: the server pings every 20s and browsers answer automatically, so a dead client is noticed (no pong in 60s → close). Browsers expose neither pings nor pongs to JavaScript, so the client's only way to notice a dead server is to ask: after 25s without any frame it sends `{"t":"ping"}` and reconnects if no `pong` arrives within 10s. ### A conversation ```text → {"t":"join","ref":"1","topic":"book:7","since":41} ← {"t":"ok","ref":"1","topic":"book:7","data":{},"replay":2,"gap":false} ← {"t":"event","topic":"book:7","seq":42,"event":"message","data":{"id":42,"body":"one","author":"AL",…}} ← {"t":"event","topic":"book:7","seq":43,"event":"message","data":{"id":43,"body":"two","author":"BO",…}} ← {"t":"presence","topic":"book:7","here":{"7":{"initials":"AL"}}} ← {"t":"presence","topic":"book:7","joins":{"9":{"initials":"BO"}},"leaves":{}} → {"t":"push","ref":"2","topic":"book:7","event":"post","data":{"body":"hi"}} ← {"t":"ok","ref":"2","data":{"id":44}} ← {"t":"event","topic":"book:7","seq":44,"event":"message","data":{"id":44,"body":"hi",…}} → {"t":"push","ref":"3","topic":"book:7","event":"post","data":{"body":" "}} ← {"t":"err","ref":"3","code":"invalid","message":"Body can't be blank"} ← {"t":"bye","code":"revoked","reason":"session revoked"} (another tab logged out) → close 4401 ``` ### Resume `seq` is chosen by the room: a durable, per-topic, increasing number. In the book room it's the message's row id. The client remembers the highest `seq` per topic and rejoins with `since`; the room replays from memory when it covers `since`, else from the database (other nodes always use the database). Replays and live events can overlap (the server subscribes before computing the replay, so nothing is lost in between), so **clients drop events with `seq <= since`**. This is the same model as SSE signals ([realtime.md#resume](realtime.md#resume)), where the cursor is the signal's id. ## Typed channels A room declares what clients may send and what it emits, as serde-tagged enums. The kernel deserializes every `push` into `Room::In` **before** the room sees it (anything else is `bad_event`), and serializes `Room::Out` into `event` frames: ```rust // src/channels/book_room.rs #[derive(Debug, Deserialize)] // TS modes: also #[derive(TS)] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum BookIn { Post(MessageParams), // {"event":"post","data":{"body":"…"}} } #[derive(Clone, Debug, Serialize)] // TS modes: also #[derive(TS)] #[serde(tag = "event", content = "data", rename_all = "snake_case")] pub enum BookOut { Message(MessageJson), // {"event":"message","data":{…}} } ``` In TS modes both enums derive `TS` (exported to `web/src/types/`), and `web/src/lib/socket.ts` is generic over them, so a typo in an event name or a field is a `tsc` error on the client and a compile error on the server. In htmx mode `static/channels.js` stays small and untyped but speaks the same protocol. ## The hub (`kernel/hub.rs`) ```rust pub type Payload = serde_json::Value; pub type Frame = axum::extract::ws::Utf8Bytes; // one offrails.v1 frame, serialized once; clones are refcount bumps pub fn event_frame(topic: &str, seq: Option, event: &str, data: &Payload) -> Frame; pub fn presence_here_frame(topic: &str, here: &Payload) -> Frame; pub fn presence_diff_frame(topic: &str, joins: &[(String, Payload)], leaves: &[(String, Payload)]) -> Frame; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Member { pub key: String, // the person (user id) pub meta: Payload, // what others see in presence: minimal (a chosen display name or its initials; never the email, nor anything derived from it) #[serde(skip)] pub session: Option, // the session this connection authenticated with; revalidated } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Rejection { pub code: String, pub message: String } // → an `err` frame pub struct Joined { pub data: Payload, pub replay: Vec<(i64, O)>, pub gap: bool } pub struct Outbox { /* … */ } impl Outbox { pub fn emit(&mut self, seq: i64, event: O); // durable: carries `seq`, replayable pub fn emit_ephemeral(&mut self, event: O); // typing, cursors: not replayed } pub trait Room: Send + Sized + 'static { type Ctx: Clone + Send + Sync + 'static; // AppState type In: DeserializeOwned + Send; // what clients may push type Out: Serialize + Send; // what the room emits fn init(ctx: &Self::Ctx, topic: &str) -> impl Future> + Send; fn joined(&mut self, ctx: &Self::Ctx, topic: &str, member: &Member, since: Option) -> impl Future, Rejection>> + Send; // owner node, from memory fn joined_elsewhere(ctx: &Self::Ctx, topic: &str, member: &Member, since: Option) -> impl Future, Rejection>> + Send; // other nodes, from the DB fn handle_in(&mut self, ctx: &Self::Ctx, topic: &str, member: &Member, msg: Self::In, out: &mut Outbox) -> impl Future> + Send; } pub struct Hub { /* Arc */ } // Clone implemented by hand (derive would require R: Clone) impl Hub { pub fn new(node: impl Into) -> Self; pub fn subscribe(&self, topic: &str, conn: u64) -> broadcast::Receiver; pub fn unsubscribe(&self, topic: &str, conn: u64); // last one out drops the topic + actor pub async fn broadcast(&self, topic: &str, seq: Option, event: &str, data: &Payload); // local + every node pub async fn track(&self, topic: &str, conn: u64, member: &Member); pub async fn untrack(&self, topic: &str, conn: u64); pub fn presence_list(&self, topic: &str) -> Payload; pub async fn join(&self, ctx: &R::Ctx, topic: &str, member: &Member, since: Option) -> Result; pub async fn handle_in(&self, ctx: &R::Ctx, topic: &str, member: &Member, event: &str, data: Payload) -> Result; pub fn revoke(&self, key: &str); // a session of `key` may have ended: wakes only `key`'s connections pub fn revoked(&self, key: &str) -> impl Future + Send + use; // per-key sender, created on demand pub fn watch_revocations(&self, signals: broadcast::Receiver) -> JoinHandle<()>; pub fn conns_of(&self, key: &str) -> usize; // per-user caps pub fn log_frames(&self, on: bool); // development: /dev/sockets frame log pub fn snapshot(&self) -> Snapshot; // for /dev/sockets pub fn close(&self); // shutdown: `bye` restart, close 1012 pub fn halt(&self); // tests: die without telling the cluster } ``` Rules the implementation follows (and tests prove): - **One actor per topic, one command at a time.** The actor is a tokio task with an `mpsc` mailbox (256). Handlers never race, so a rate limit or a sequence number needs no lock. 200 concurrent `inc` commands on a counter room end at exactly 200. - **Supervision ("let it crash", per room).** Every `init`/`joined`/`handle_in` runs under `AssertUnwindSafe(..).catch_unwind()`. A panic answers the caller with `unavailable` ("room crashed; try again"), drops the state, counts a restart (shown on `/dev/sockets`), and the next command rebuilds the state with `init`. The mailbox and the connections survive. - **Serialize once.** `Hub::broadcast` builds one `Frame` and every subscriber receives a clone of the same buffer. The test asserts 1000 receivers share one pointer. - **Slow consumers are warned, then dropped.** Each topic's channel buffers `TOPIC_BUFFER` (256) frames. At ¾ full the connection gets a `slow` frame; past the buffer it gets `bye` (`too_slow`) and close 1013. The client reconnects and resumes from `since`. One slow phone can't make the server hold memory for everyone. - **Wake only who it's for.** Topics have one sender each (`subscribe(topic, conn)`), and revocations are keyed the same way: `revocations: Mutex>>` by member key, created by `revoked(key)`, swept when a connection unregisters and the sender has no receivers. Never one process-wide channel that every connection filters: a logout would wake every socket and stream on the node (the same rule as [pubsub](realtime.md#kernel-spec-srckernelpubsubrs)). - **Idle topics are forgotten.** No local connections and nobody present → the topic (and its actor) is dropped. An actor with no commands for 5 minutes stops by itself. - **Presence lists people, not tabs.** Refs are `(node, conn)`. A person joins when their first ref arrives and leaves when their last ref goes. Two tabs = one avatar. - **Every join and rejoin gets a full snapshot before any diff.** The server orders the frames (above); the client's `Presence` also defends itself: on each (re)join it marks itself unsynced, **buffers** `joins`/`leaves` until that join's `here` arrives, replaces its state with `here`, applies the buffered diffs, and only then calls `onSync`. Diffs are idempotent (a `joins` for someone already present or a `leaves` for someone absent changes nothing), so overlap between the snapshot and buffered diffs is harmless. - **After a server restart, presence converges as clients come back.** The restarted node starts with nobody present; each client reappears when it rejoins, so the first one back sees only itself until the others reconnect. That window is the reconnect backoff (≤ 10 s, **with ±25% jitter** so clients don't return in lockstep), not a bug, but it must close: a test restarts the server with two clients connected and asserts both see both within backoff + 1 s. (Found in a real build: one client briefly saw only itself after a restart.) ## The connection (`kernel/channels.rs`) ```rust pub const PROTOCOL: &str = "offrails.v1"; pub trait Channels: Clone + Send + Sync + 'static { // implemented by AppState type Room: Room; fn hub(&self) -> &Hub; fn socket_config(&self) -> SocketConfig; /// Against the database as it is now (also re-checked on every push). fn authorize(&self, member: &Member, topic: &str) -> impl Future> + Send; /// Is this connection's session still valid? Fails closed. fn session_valid(&self, member: &Member) -> impl Future + Send; } pub struct SocketConfig { pub allowed_origin: String, // origin of APP_URL pub max_frame: usize, // 4 KiB pub max_in_per_10s: u32, // 100 pub idle_timeout: Duration, // 60s without any frame or pong pub ping_every: Duration, // 20s (WebSocket control frames) pub revalidate_every: Duration, // 60s (APP_REALTIME_REVALIDATE_SECS) pub max_conns_per_user: usize, // 128, all transports, per node (a tab can hold 2: size for tabs) pub max_topics: usize, // 20 per connection pub max_joins_per_min: u32, // 30 per connection } /// The protocol state of one client, whatever the transport: joined topics, limits, frame logic. pub struct Session { /* … */ } impl Session { pub async fn handle_text(&mut self, raw: &str) -> Next; // rate limit, parse, dispatch pub async fn next_broadcast(&mut self) -> Result, TooSlow>; // + `slow` warnings pub async fn end(self); // leave every topic } pub fn upgrade(s: S, ws: WebSocketUpgrade, headers: &HeaderMap, member: Option) -> Response; pub fn sse_open(s: S, headers: &HeaderMap, member: Option) -> Response; pub fn poll_open(s: S, headers: &HeaderMap, member: Option) -> Response; pub async fn poll_get(s: S, headers: &HeaderMap, member: Option, conn: u64) -> Response; pub async fn relay_post(s: S, headers: &HeaderMap, member: Option, conn: u64, body: String) -> Response; pub fn session_ended(s: S, member: Member) -> impl Future + Send; // shared by every transport ``` **Per-socket buffers: set them in `upgrade`.** axum's `WebSocketUpgrade` defaults to a 128 KiB read buffer and a 128 KiB write buffer per socket, sized for throughput, not for ten thousand idle board watchers. offrails.v1 frames are ≤ 4 KiB, so: ```rust // kernel/channels.rs, upgrade(): verified against axum 0.8.9's WebSocketUpgrade ws.protocols([PROTOCOL]) .read_buffer_size(4 * 1024) // frames are ≤ 4 KiB (max_frame) .write_buffer_size(0) // write each frame straight through; no per-socket staging copy .max_write_buffer_size(256 * 1024) // backpressure cap when writes fail; the default is unlimited .max_message_size(cfg.max_frame) .max_frame_size(cfg.max_frame) .on_upgrade(move |socket| run(s, socket, member)) ``` Measured by a peer at **10,000 sockets** on one box (a Kanban, release build): server memory **1.37 GB → 160–225 MB**, failed handshakes **17 → 0**, fan-out p50 **320–590 ms → 58–88 ms**. Slow consumers are still handled by `TOPIC_BUFFER` and `slow`/`too_slow`, not by the socket's write buffer. All three transports drive the same `Session`, so joins, authorization, limits, resume and revocation are identical everywhere. Per WebSocket, one task `select!`s over: incoming frames, the joined topics' receivers, the ping timer, the idle check, the revalidation timer, the member's revocation signal, and `hub.closed()`. | Situation | What happens | |-----------|--------------| | `Origin` missing or not exactly APP_URL's origin | **403** at the handshake (CSWSH: browsers send cookies on cross-site WebSocket handshakes, and CSRF protection doesn't cover them) | | Logged out | **401** at the handshake | | No `offrails.v1` subprotocol offered | the socket opens, gets `bye` `protocol` "subprotocol offrails.v1 required", closes **1002** (browsers hide an HTTP refusal's reason). A client offering only other subprotocols can't connect at all | | More than `max_conns_per_user` | `bye` `limit`, close **1008** (HTTP transports: 429). Test it at the boundary without opening 128 sockets: hold `cap - 1` slots with `ConnGuard::register(&state, &member, "test")`, then one real socket joins and the next gets 1008. Opening `cap` real sockets slowed the Postgres suite from 80 s to 150 s and starved timing-sensitive tests | | Join | limits (`max_topics`, `max_joins_per_min`), `authorize`, then **subscribe before computing the reply** (nothing broadcast in between is lost), `track`, then `ok` with `replay` n, the replayed `event`s, and `presence` `here` **computed after `track`**, so the snapshot includes the joiner. Diffs for the topic that the subscription received meanwhile are sent after `here`, never before | | Push | `not_joined` unless joined; **re-`authorize`** (a reader removed since joining is refused and leaves the topic); the room's `In` type (`bad_event`); then the room | | More than `max_in_per_10s` frames | `bye` `rate_limited`, close **1008** | | Not JSON / an array / unknown `t` / binary | `bye` `protocol`, close **1002** / **1003** | | Frame over `max_frame` | tungstenite rejects it, and the socket closes | | No pong (or anything) for `idle_timeout` | close **1001** | | Logout, password change, revocation, expiry | `bye` `revoked`, close **4401** ([Security](#security)) | | Shutdown (`hub.close()`) | `bye` `restart`, close **1012**, so clients reconnect, possibly to another node | ## Transports A client tries the WebSocket first and falls back automatically: | Order | Transport | Down | Up | Gets through | |-------|-----------|------|----|--------------| | 1 | **WebSocket** (default) | `GET /socket` (offrails.v1) | same socket | most networks | | 2 | **SSE + POST** | `GET /socket/sse`: event `hello` `{conn, token}`, then `frame` events (one frame each) | `POST /socket/sse/{conn}` (one frame, header `x-offrails-token`) → 202 | almost every proxy (plain HTTP) | | 3 | **Long-polling** | `POST /socket/poll` → `{conn, token}`; `GET /socket/poll/{conn}` waits ≤ 25s, returns a JSON array of frames | `POST /socket/poll/{conn}` → 202 | anywhere HTTP works | - **Choosing:** the client uses the transport that worked last in this tab (`sessionStorage`), else the first. A transport that doesn't open within 5s, or closes before opening, counts as blocked: the client moves to the next one. - **Upgrading back:** on a fallback, the client probes the WebSocket every 60s; when it opens, it switches over and rejoins (resuming from `since`). - **Same guarantees:** relays (SSE, long-poll) are `Session`s like sockets. Replies to posted frames come down the stream or the next poll. A relay is bound to the member who opened it **and** a random 256-bit token (compared in constant time): another user, or the same user without the token, gets 403. POSTs pass the app's CSRF layer; GETs refuse a foreign `Origin` or `Sec-Fetch-Site: cross-site`. Body limit = `max_frame` (413); a full inbox = 429; an ended relay = 410 after its `bye` was delivered. - **`APP_REALTIME_TRANSPORTS`** (default `websocket,sse,longpoll`) lists what the server offers. Behind a proxy that mishandles WebSockets, drop `websocket`: the endpoint answers 404 like a blocking proxy, and clients fall back. The Playwright spec uses this to force SSE, then blocks `/socket/sse` with `context.route` to force long-polling. ## Security Realtime connections are long-lived and authenticated once, so every rule a request gets for free has to be enforced for the connection's whole life. **Security is the gold standard here; each rule has a test** (`tests/app/wire_test.rs`, `kernel_pubsub_test.rs`). Every transport (the WebSocket upgrade, `/socket/sse` + POST, and long-polling) authenticates through the same session extractor as ordinary requests (`load_session`, via `SessionAndUser`). So all of them inherit the production `__Host-session_token` name and ignore a planted plain `session_token`, and their responses pass through the security middleware (`no-store`, the full header matrix; [security](security.md#the-header-matrix)). Never parse the session cookie by hand in realtime code. | Rule | How | Test | |------|-----|------| | **Same-origin only, every transport** | WebSocket: `Origin` must equal APP_URL's origin (missing = refused). SSE / long-poll GETs: refuse a foreign `Origin` or `Sec-Fetch-Site: cross-site`. POSTs: the CSRF layer. Session cookies are `SameSite=Lax` too | cross-site Origin on WebSocket, SSE stream, SSE relay, long-poll, relay POST | | **Logout, password change, revocation close connections at once** | `session::destroy` / `destroy_all_for_user` write a `member:` / `session-ended` signal (pubsub, so every process and node sees it). The hub wakes that member's connections; each re-checks **its own** session: the revoked one gets `bye` `revoked` + **4401**, the same person's other devices stay | logout closes that session's sockets only; password change closes all; logout ends SSE streams with `bye`; logout ends relays | | **Expiry closes connections** | every `revalidate_every` (60s, `APP_REALTIME_REVALIDATE_SECS`) each connection calls `session_valid` (fails closed) | expired session closed by revalidation (socket and SSE) | | **Authorization against current state** | joins and **every push** call `authorize` against the database now; a refused push also leaves the topic | push after the book is deleted → `forbidden`, then `not_joined` | | **Per-user caps** | ≤ 128 connections per member (all transports, per node; each tab can hold 2, and a cap of 8 broke users at ~5 tabs, see [realtime](realtime.md#many-tabs-stall-http11-vs-http2)), ≤ 20 topics per connection, ≤ 30 joins per minute per connection, ≤ 100 frames per 10s, frames ≤ 4 KiB | each limit | | **Presence leaks nothing** | only members of the topic (who passed `authorize`) receive its presence; `meta` is minimal: a display name the person chose (or its initials), else a neutral `Member `; **never the email, nor initials or a name derived from it** (`ann.lee@…` → "AL" tells a room who's there) unless the spec says so | no `@` in presence frames; a user with no display name shows as `Member ` | | **Relays can't be hijacked** | `{conn, token}`: owner member + 256-bit token, constant-time comparison | wrong token, other member, unknown conn | | **Untrusted input stays data** | typed `In` enums (unknown events refused before the room), text rendered with `textContent` | `bad_event`; HTML delivered raw, rendered escaped | The client reacts to `bye` `revoked` by **not reconnecting** (it would only loop into 401s) and fires `offrails:revoked` on `window`; the page can redirect to the login form. ## The app side ```rust // src/channels/mod.rs pub async fn socket(State(s): State, SessionAndUser(found): SessionAndUser, headers: HeaderMap, ws: WebSocketUpgrade) -> Response { if !offered(&s, "websocket") { return StatusCode::NOT_FOUND.into_response(); } kchannels::upgrade(s, ws, &headers, member_of(&found)) } // socket_sse, socket_poll_open, socket_poll, socket_send: the same, calling sse_open / poll_open / poll_get / relay_post. pub fn member_for(u: &User, session: Option) -> Member { Member { key: u.id.to_string(), meta: json!({ "initials": user::initials(u) }) /* from u.name, else "M"; never from u.email */, session } } /// Topics: `book:{id}` (presence + discussion) and `user:{id}` (private notices). pub enum AppRoom { Book(BookRoom), Private } impl Room for AppRoom { type Ctx = AppState; type In = BookIn; type Out = BookOut; /* dispatch on the topic prefix */ } impl Channels for AppState { type Room = AppRoom; fn hub(&self) -> &Hub { &self.hub } fn socket_config(&self) -> SocketConfig { /* SocketConfig::new(app_url) + revalidate_every from config */ } async fn authorize(&self, member: &Member, topic: &str) -> Result<(), Rejection> { /* user:{id} → own id only; book:{id} → books::authorize, against the DB now */ } async fn session_valid(&self, member: &Member) -> bool { /* session::is_live(db, member.session) */ } } ``` - **Routes** (with `ROUTES` rows, `Access::User`): `GET /socket`, `GET /socket/sse`, `POST /socket/sse/{conn}`, `POST /socket/poll`, `GET`+`POST /socket/poll/{conn}`. Keep the poll routes explicit: a POST to `/socket/poll/1` matching a GET-only static route answers 405 instead of falling through to a `{transport}` param route. - **Extractor:** `SessionAndUser` (session + user), so the connection knows which session to revalidate. - **State:** `hub: Hub::new(node)`, `hub.log_frames(config.is_development())`, `hub.watch_revocations(pubsub.subscribe_prefix("member:"))` in `AppState::new`. - **`serve`:** `kernel::cluster::start(..)` when `config.cluster`, and `hub.close()` next to `pubsub.close()` in the graceful-shutdown future, which is bounded by `APP_SHUTDOWN_DRAIN_SECS` ([realtime](realtime.md#kernel-spec-srckernelpubsubrs)). Every socket task `select!`s on `hub.closed()` and sends `bye` `restart` + 1012 without awaiting anything else. - **A room** (`src/channels/book_room.rs`) keeps the newest 50 messages in a `VecDeque` and knows whether that covers a client's `since`; it goes to the database only when it doesn't. `handle_in(BookIn::Post)`: 1. per-reader rate limit (10 per 10s, `rate_limited` "Slow down"), consistent across tabs and nodes because only the owner's actor sees posts; 2. validate (`MessageParams::validate`, the same `Errors` as forms; `invalid`); 3. **insert**; 4. push to memory; 5. `out.emit(row.id, BookOut::Message(json))`; 6. reply `{id}`. - **The page** renders the newest 50 messages server-side, escaped by Askama, so the first paint needs no socket. The client joins with `since = max(data-id)` and gets only newer messages. ## The JS client `static/channels.js` (htmx mode; loaded in the layout before `app.js`) defines `window.OffRails = { Connection, Presence, transports, socket() }`. TS modes put the same code, typed by the room's exported `In`/`Out` types, in `web/src/lib/socket.ts`. ```js const ch = window.OffRails.socket().channel("book:7", { since: lastSeen }); // the cursor advances with every `seq` ch.on("message", add); // typed Out event ch.onJoin(() => (error.textContent = "")); ch.on("gap", () => location.reload()); // missed more than the room can replay ch.onError((e) => (error.textContent = e.message)); // {code, message} new window.OffRails.Presence(ch).onSync((people) => render(people)); // [{key, initials}] form.addEventListener("submit", (e) => { e.preventDefault(); ch.push("post", { body }).then(clear).catch((e) => show(e.message)); }); ch.join(); addEventListener("offrails:revoked", () => (location.href = "/session/new")); // optional ``` - **One connection per tab** (`OffRails.socket()`), many channels, any transport. - **Reconnect** with backoff 250ms → 1s → 2s → 5s → 10s, each ±25% jitter; every wanted channel rejoins with its current `since`, and its `Presence` goes unsynced until the new `here`. - **`join()` before the connection is open just waits.** - **Replies are promises** with a 10s timeout; rejections are `{code, message}`. - **Render user text with `textContent`, never `innerHTML`.** - **htmx pages:** - start rooms on `DOMContentLoaded` and `htmx:load`; - on `htmx:beforeSwap` with `detail.boosted`, leave the page's channels, so presence updates for everyone else when you navigate away; - put **`hx-boost="false"` on any form the socket sends**: a boosted form submit also navigates (to `?body=…`), which the browser check caught. ## Live boards over a channel (snapshot, patches, reducer) For a page whose whole state many people watch (a Kanban board, a seating plan), the channel carries the state itself instead of "re-fetch" signals, so a change costs one small frame per watcher and **no re-fetch**. Measured by a peer: **10,000 watchers** on one board, delivery p50 **58–88 ms**, zero re-fetches (with the buffer settings above). 1. **The join's `ok.data` is the starting point.** Joined **without** `since` (a fresh page): `data` is the full JSON snapshot plus the `seq` it reflects (`{"seq": 812, "board": {…}}`), read in one query, after subscribing (the join rule above). Joined **with** `since` (a reconnect): `data` holds only the current `seq`, followed by `replay` patches after `since`; `gap: true` (more missed than the room keeps) means the client discards its state and rejoins without `since` to get a snapshot. 2. **Patches carry final state, not deltas.** A move is `{"event":"card_moved","seq":813,"data":{"card":7,"column":3,"order":[5,7,9]}}`: the column's **whole final order**, never "insert at index 2". The client applies every patch through **one pure reducer**, `(state, patch) → state`, which ignores any patch with `seq ≤ state.seq` and sets fields to the patch's values. It's idempotent: a replayed or duplicated patch changes nothing, and replay/live overlap is harmless. Unit-test the reducer alone (Vitest in TS modes, or a small `node --test` file): applying a patch twice equals once; an old `seq` is ignored; a move then the snapshot it produced give the same state. 3. **Actions stay HTTP POSTs.** Creating, moving and editing go through the normal routes (authorization, CSRF, validation, the same `Errors` as forms), never `push` on the socket. The client applies its own change **optimistically** through the same reducer, and the channel's patch, which every watcher including the actor receives, **confirms** it: the server's final order wins. A failed POST rolls back by re-applying the last confirmed state. 4. **While the socket is down, poll a JSON snapshot.** The client keeps showing its state, marks it stale, and polls `GET /boards/{id}.json` (the same snapshot as `ok.data`, same authorization) every few seconds until the socket reconnects; then it rejoins with its `since`. The page never goes silently stale and never reloads. The room (`src/channels/board_room.rs`) writes the change in the POST's transaction (rows plus an event row whose id is the `seq`), and the forwarder below turns committed event rows into patches. Server tests: a join without `since` returns the snapshot and its `seq`; with `since`, exactly the patches after it, in order; a move's patch carries the whole final order; a reader without access gets no patches (authorization at join, as for every topic). ## Feeding a topic from rows (forwarders) When events are written by HTTP handlers or another process (an outbox, `board_events`, chat rows posted over HTTP), a **forwarder** task turns new rows into `Hub::broadcast` frames. If it reads with a **look-back window** (rows of the last few seconds, or the last K ids, because a Postgres sequence can commit a lower id after a higher one) and skips what it already sent with an in-memory `forwarded` set, that set starts **empty at boot**, so the first poll after a restart forwards the whole window again: every client gets old events a second time, and a list reducer without the `seq` check reorders on them. A peer's Kanban, its board events and its chat all did this. **Seed `forwarded` from the window at start**, before the first poll: read the ids of every row inside the window and mark them sent without sending them. Anything committed after that read isn't in the set, so it's still forwarded. ```rust pub struct Forwarder { db: DatabaseConnection, window: chrono::Duration, forwarded: HashSet } impl Forwarder { /// Marks every row already inside the window as forwarded: a restart replays nothing. pub async fn start(db: DatabaseConnection, window: chrono::Duration, now: DateTimeUtc) -> Result { let forwarded = board_event::ids_since(&db, now - window).await?.into_iter().collect(); Ok(Self { db, window, forwarded }) } /// Sends each row in the window not sent yet, oldest first; forgets ids that left the window. pub async fn poll_once(&mut self, hub: &Hub, now: DateTimeUtc) -> Result; } ``` Test (`kernel_channels_test.rs` or the room's test file): insert 3 events, `Forwarder::start` (a restart), insert a 4th, `poll_once`: a subscriber receives **exactly the 4th**. A second `poll_once` sends nothing. Pass `now` in; never sleep. The pub/sub poller doesn't have this bug: it `seek_to_end`s at start and reads `id > last_seen` ([realtime](realtime.md#kernel-spec-srckernelpubsubrs)). ## Presence over SSE (no WebSocket) For a page that already holds a signal stream and only needs **how many are here** (a board viewed by 5,000 people), a socket per tab is overkill: count the streams. Measured by a peer on a Kanban (5,000 SSE watchers on one board, release build, one M2): it works, **if updates are coalesced**. Without coalescing, 5,000 arrivals wake 5,000 receivers 5,000 times (25 million wake-ups to load one board). **The rules:** 1. **A join guard per stream.** The stream endpoint calls `s.presence.join(&stream, user.id)` and moves the returned `JoinGuard` into the response stream's state. Its `Drop` is the leave: a closed tab, a dropped connection, a shutdown, an error path. No explicit leave call to forget. 2. **Count people and tabs:** people = distinct user ids, tabs = streams. The label is built from numbers only (`12 people · 15 tabs`): presence never carries names or emails ([security](#security)), and numbers need no escaping when htmx swaps them in. 3. **Joins and leaves only mark the room dirty.** They update the counts under a `std::sync::Mutex` and insert the room in a dirty set; they never send. 4. **One task publishes:** every 250 ms it takes the dirty set and, per dirty room, computes the label once and sends it on that room's `tokio::sync::watch` channel (`send_if_modified`, so an unchanged label wakes nobody). `watch` keeps only the latest value, so a burst of 5,000 joins inside one tick is one wake-up per receiver. 5. **The current label on connect, then one event per change.** The stream merges `WatchStream::new(rx)` (tokio-stream, feature `sync`): it yields the current value first, then each new one, as `event: presence`. The page swaps it into a span: `…` inside the `sse-connect` element. 6. **Per process.** Counts are in memory. With more than one node, each node sees only its own tabs: back it with the pub/sub table (each node publishes its per-room counts with its node id, and the label sums the latest count from every live node) or use the channels hub's cluster presence. Not specified further until an app needs it; say so in the spec instead of shipping a per-node count. ```rust // src/kernel/presence.rs: counted presence over signal streams (one per process, in AppState) #[derive(Clone, Default)] pub struct SsePresence { inner: Arc> } // std::sync::Mutex: never held across .await #[derive(Default)] struct Rooms { rooms: HashMap, dirty: HashSet } struct Room { people: HashMap, tabs: usize, tx: tokio::sync::watch::Sender } pub struct JoinGuard { presence: SsePresence, room: String, user_id: i64 } impl Drop for JoinGuard { fn drop(&mut self) { self.presence.leave(&self.room, self.user_id) } } // leave = mark dirty impl SsePresence { /// Count this stream in; returns the guard (hold it in the stream) and the label receiver. pub fn join(&self, room: &str, user_id: i64) -> (JoinGuard, tokio::sync::watch::Receiver); fn leave(&self, room: &str, user_id: i64); // tabs -= 1; a user at 0 tabs leaves `people` /// Publish one label per dirty room; drop rooms with no tabs. Returns how many rooms it published. pub fn flush(&self) -> usize; /// `serve` spawns this once (after realtime starts): flush every 250 ms until shutdown. pub fn start(&self, every: Duration, shutdown: impl Future + Send + 'static); pub fn label(&self, room: &str) -> Option; // tests, /dev/introspect.json } ``` A new room's `watch` starts with the label as of its first join, so the joiner sees itself right away; `flush` corrects every other viewer within 250 ms. In the stream endpoint (realtime.md), next to the signals: ```rust let (guard, label) = s.presence.join(&stream, user.id); let presence = WatchStream::new(label) .map(move |text| { let _held = &guard; Ok::<_, Infallible>(Event::default().event("presence").data(text)) }); // merge `presence` with the signal stream, then take_until(ended) and take_until(pubsub.closed()) as before ``` Tests (`kernel_presence_test.rs`; call `flush()` yourself, never start the ticker, like `poll_once`): - two users, three guards: after `flush()` the label is `2 people · 3 tabs`; drop one guard of the user with two: `2 people · 2 tabs`; drop the rest: the room is gone (`label` is `None`); - **coalescing:** subscribe one receiver, join 1,000 guards, `flush()` once: the receiver's `changed()` resolves once and `has_changed()` is then false; `flush()` returns 1; - an unchanged label after a flush (a join and a leave in the same tick) wakes nobody; - the stream endpoint's first `presence` event arrives before any signal, and closing the stream (dropping the body) decrements the count after the next `flush()`. ## More than one server (`kernel/cluster.rs`) **Staging rule.** Pick the first stage that fits and move on only when the spec needs it: 1. **One node** (the default; SQLite or Postgres). Everything is in memory in one process. One Rust server holds tens of thousands of sockets. 2. **Several stateless nodes behind a load balancer** (Postgres). Jobs, cache and SSE signals already work across nodes through the database (`NOTIFY` wakes every node). No cluster layer. 3. **The cluster layer** (`APP_CLUSTER=true`, Postgres only). Add it only when channels with rooms or presence must span nodes. The cluster layer is **the database as the coordinator**, no new infrastructure: - **Membership:** each node upserts `cluster_nodes(node, started_at, heartbeat_at)` every `heartbeat` (2s) and reads everyone fresher than `dead_after` (6s). The node name is `APP_NODE`, defaulting to `:`. - **Messages:** `pg_notify('offrails_cluster', json)`, received by a `PgListener` per node. Each message is `Fanout`, `Presence`, `Hello` (a new node asks for everyone's presence), `Call` or `Reply`. - **Ownership:** rendezvous (highest random weight) hashing of `(node, topic)` with FNV-1a. It must be a fixed hash, because std's hasher is seeded per process and nodes would disagree. Every node computes the same owner from the same member list, and when a node leaves, only its topics move (the test checks that about 1/3 move when one of three leaves). - **`handle_in` on a non-owner** becomes a `Call` to the owner, which replies with `Reply` within `call_timeout` (3s). If the call fails and the topic's owner changed meanwhile (the owner died), it's retried once against the new owner. - **Joins on a non-owner** use `Room::joined_elsewhere`, which reads from the database. Replays can be larger than a NOTIFY allows. - **Presence:** each node announces its own refs. A node marked dead is removed from every topic, and the other nodes send `presence` `leaves`. - **Revocations** need no cluster message: they're pubsub signals (`member:`), which every node already receives. - **Failover:** a dead owner's topics move to survivors. Their actor `init`s from the database. Clients that lost their node reconnect (1012 or a dropped TCP connection) to any node and resume from `since`. - **Graceful leave:** on `hub.close()` the node deletes its `cluster_nodes` row, so the others react on their next heartbeat instead of after `dead_after`. **Why NOTIFY instead of node-to-node HTTP:** - no internal port, shared secret, or private-network setup to get wrong on each host; - it works anywhere the app can reach its database. The costs: - payloads are capped at 8,000 bytes (`MAX_NOTIFY` = 7,900). That's why socket frames are capped at 4 KiB and replays never cross nodes; - throughput is bounded by Postgres, comfortably thousands of messages per second. If a product outgrows that, the escape hatch is an HTTP mesh between nodes, logged in `docs/decisions.md`. **Not provided** (and not needed for a web app): process ids that work across nodes, hot code reload, and supervision across nodes. ## `/dev/sockets` A kernel page in the `/dev` tab bar (same rules as every kernel page: own layout, `no-store`, `hx-boost="false"` links into it). It polls every 2s and shows: - this node's name, connections, topics, frames in and out, and the rate since the last poll; - room restarts, flagged in red if any; - live nodes; - per topic: local connections, people present, owner, whether its actor is running, and broadcasts sent; - per connection: member, **transport** (`websocket`, `sse`, `longpoll`), topics, age, frames in and out; - **a live decoded frame log** per connection (spec below). **The frame log** (what the reference app implements; a `new` app gets it with the first channel): ```rust // kernel/hub.rs pub const FRAME_LOG: usize = 40; // frames kept per connection (oldest dropped) const FRAME_LOG_TEXT: usize = 400; // bytes kept per frame, cut on a char boundary, then "…" pub struct LoggedFrame { pub dir: &'static str /* "in" | "out" */, pub at: DateTime, pub text: String } // ConnInfo gains `frames: VecDeque`; Snapshot carries it to the page. impl Hub { pub fn log_frames(&self, on: bool); // AppState::new: on in development only pub fn log_frame(&self, conn: u64, dir: &'static str, text: &str); // no-op unless on } ``` - **Where it's recorded:** in the transport-independent protocol state (`kernel/channels.rs`), once per frame: `log_frame(conn, "in", raw)` as each client frame is read (before decoding, so a malformed frame shows as sent), and `log_frame(conn, "out", json)` as each server frame is encoded. Every offrails.v1 transport (WebSocket, SSE + POST, long-poll) goes through it. Signal streams under `/streams/` carry no frames and show only their transport. - **What it shows:** the offrails.v1 JSON text as sent (`{"t":"push","ref":"3","topic":"book:1",…}`), newest first, with ↑/↓ and the time. Pings and pongs are frames too. Development only: with `log_frames(false)` nothing is copied, so production keeps no message text. - **Tests** (`kernel_channels_test.rs`): with logging on, a join and a push appear as an `in` frame and the `ok`/`event` replies as `out` frames; the 41st frame drops the oldest; a 1 KB frame is cut to 400 bytes plus "…"; with logging off the deque stays empty. ## Measured (reference app, M-series laptop under heavy parallel load, 2026-10-12) | What | Result | |------|--------| | Fan-out, 1,000 in-process subscribers (send → last receiver woken) | p50 **1.3 ms** | | Fan-out, 10,000 subscribers | p50 **12 ms** | | WebSocket round trip, localhost (`ping` → `pong`) | p50 **66 µs**, p90 91 µs | | SSE signal latency, SQLite, before (250ms poll) → after (in-process wake) | **238 ms → 0.24 ms** | | SSE signal latency, Postgres, before → after (`NOTIFY`) | **238 ms → 2.1 ms** | A peer's load test on 0.6.1 (one box, WebSocket channels, a board as snapshot + patches): **10,000 sockets**, 160–225 MB, 0 failed handshakes, delivery p50 58–88 ms, after the [buffer settings](#the-connection-kernelchannelsrs) (1.37 GB and 17 failed handshakes before). **At 128 connections per user, 10,000 sockets need at least 79 users**: with fewer, the excess is refused (`bye` `limit`, 1008) and shows up as "closed" in a load generator. A peer's load test on 0.6.0 (one box, signal streams): **5,000 SSE watchers held, 154 MB RSS, fan-out p50 ≈ 40 ms**; the database and page rendering, not open streams, were the bottleneck ([realtime](realtime.md#measured-capacity)). Run them with `cargo nextest run --run-ignored only -E 'test(bench_)' --no-capture` (and with `TEST_DATABASE_URL` for Postgres). ## Testing `tests/app/support.rs`: - **`serve(state) -> (addr, JoinHandle)`:** sockets need a real port; abort the handle to kill a node. - **`session_cookie(db, user)`** / **`session_for(db, user) -> (session id, cookie)`:** one browser per user; the id lets a test end exactly that session. - **`Ws`**, an offrails.v1 client: - `Ws::connect(addr, cookie, origin) -> Result` (offers `offrails.v1`; surfaces handshake 401/403), `connect_with_protocol(.., None | Some("other"))`, `Ws::as_user(addr, cookie)`; - `join(topic, since) -> Result` (collects the replayed events after the `ok`); - `push(topic, event, data) -> Result`, `leave(topic)`, `request(frame)`; - `event(topic, event)`, `presence(topic)`, `recv_where(pred)` (keeps non-matching frames for later), `quiet(pred, ms)`; - `closed() -> (close code, bye code)`, `closed_with() -> close code`. - **Relays:** `open_stream("/socket/sse")` + `next_sse_data` reads `hello` and `frame` events; post frames with a same-origin request carrying `x-offrails-token`. Required cases, per channel: - **join authorization:** logged out gets 401, a foreign Origin gets 403, and someone else's private topic, an unknown topic, or a missing record each give `forbidden` / `unknown_topic`; - **delivery:** a message reaches others and is stored, `seq` equals its id, and validation errors carry `invalid` + the reason; an undeclared event is `bad_event`; - **presence:** `here` (including the joiner) before any `joins`/`leaves`, two tabs counting as one person, no emails in metadata, both clients see each other within backoff + 1 s after a server restart; - **resume:** after a reconnect with `since`, exactly the missed events arrive, in order, from memory and from the database; - **limits:** the per-room rate limit, an oversized frame, flooding (1008), garbage / unknown `t` / legacy arrays (1002); - **output:** HTML in messages is delivered raw and rendered escaped. Protocol and security cases (`wire_test.rs`, once per app): subprotocol required (1002 + `bye`) and other subprotocols refused; unknown fields ignored; `ping`/`pong`; connection, topic and join caps; logout closes that session's sockets only (4401), password change closes all, expiry closes by revalidation; pushes re-authorized after access is lost; SSE + POST and long-polling speak the same protocol (and talk to WebSocket clients in the same room); relays need owner + token + same origin; logout ends relays; cross-site refused on every transport. Kernel cases (`kernel_channels_test.rs`): - **supervision:** a panic gets `unavailable`, `init` runs again, and the restart is counted; - one command at a time; typed `In` refuses undeclared events; - serialize-once (pointer equality); - presence counting and idle topic cleanup; - **keyed wake-ups:** `revoke("user:1")` resolves `revoked("user:1")` and leaves `revoked("user:2")` pending (`tokio::time::timeout(100ms)` errs); after the last connection of `user:1` unregisters, its revocation entry is gone; - **ownership:** owners are stable, and a leave moves only that node's topics; - **signals:** a committed signal is delivered with the backstop poll set to 60s, and a rolled-back one never is; - **the 3-node cluster test** (Postgres only): presence crosses nodes, a message on the owner reaches a socket on another node, `halt()` the owner, presence leave, posting on the new owner, the reconnecting client resumes from a third node, each message is stored once, and jobs drained by two nodes run exactly once. Real browser (`tests/e2e/realtime.spec.ts`, Playwright; the spec starts and stops its own servers by handle): - **downtime:** a page reached by a boosted form, its server stopped, a review changed and a chat message posted through a second process, the server restarted: the open page shows both without a reload; - **fallback:** with `APP_REALTIME_TRANSPORTS=sse,longpoll` the page runs on SSE (presence, chat both ways); with `/socket/sse` blocked too, on long-polling. ## Gotchas agents get wrong - **Checking `Origin` only when present** on the WebSocket. A missing `Origin` must be refused too: browsers always send it on WebSocket handshakes. - **Broadcasting before writing.** If the insert fails, everyone already saw a message that doesn't exist. Write, then `emit`. - **Resume cursors that aren't durable.** `seq` must survive restarts and node moves (a row id), not an in-memory counter. - **Filtering replays by "id <= cursor" on the server.** Postgres sequences can commit out of order; dedupe on the client by `seq` and replay exact ids. - **Subscribing lazily.** Subscribe to the topic before computing the join reply, and to revocations before the first await, or what happens in between is lost (the browser check found a logout missed this way). - **Replaying from the actor's memory on every node.** Only the owner has it. Elsewhere use `joined_elsewhere` (database). - **Sending replays through `NOTIFY`.** 8,000-byte cap. Replies over the cluster must stay small. - **Using `std::collections::hash_map::DefaultHasher` for ownership.** It's seeded per process. Use the fixed FNV-1a in `cluster.rs`. - **`#[derive(Clone)]` on `Hub`.** That requires `R: Clone`. Implement `Clone` by hand. - **`async fn` in a trait used with `tokio::spawn`.** Declare `-> impl Future + Send` in the trait. `async fn` in the impl is fine. - **Timing assertions.** `elapsed() < 150ms` passed alone and failed under parallel load. Prove a fast path by making the slow path impossible (`start_with(60s)`), not by racing a clock. - **A dying node announcing its leaves.** A crash says nothing; the others learn it from heartbeats. `halt()` publishes nothing, so tests exercise the real path. - **Raw `PgListener::recv()` inside a `select!` with other branches.** If another branch wins, the `recv` future is dropped mid-read. Give the listener its own task and forward over a channel ([realtime.md](realtime.md)). - **No `hx-boost="false"`** on the socket's form (see the JS client section). - **Treating an `EventSource` reopen as the same connection** on the SSE transport: a reopen is a *new* server session with no joins. Close it and reconnect through the client, which rejoins. - **A forwarder whose `forwarded` set starts empty** with a look-back window: the first poll after a restart re-sends the window. Seed it at start ([forwarders](#feeding-a-topic-from-rows-forwarders)). - **Board patches as deltas** ("insert at index 2") or a reducer without the `seq` check: replays and duplicates reorder lists. Send the final order; reduce idempotently. - **Relay ids without a token**, or a token compared with `==` on secret material: bind relays to member + token, compare in constant time. - **`Result<_, Response>` in kernel helpers:** clippy's `result_large_err`; return a `StatusCode` and convert at the edge. ================================================================ FILE: guides/config.md (https://rustoffrails.org/docs/guides/config.md) ================================================================ # Config, Environments & Secrets > **TL;DR** > - One `Config` struct parsed from env vars once (`Config::from_env`); never `std::env::var` deep in the code. > - `APP_ENV` = development | test | production; `.env` is loaded only in development; `.env.example` lists every variable with dev defaults (no real secrets). > - In development `APP_URL` follows `PORT`; normalize it (no trailing slash). > - `serve` listens on `APP_BIND`: 127.0.0.1 in development, 0.0.0.0 in production; an unset `APP_ENV` (= development) warns at every boot. > - Secrets live in the deploy platform's secret store, never in git or images. > > Read the rest when adding a setting, a secret, or environment-specific behavior. | Rails | Off Rails | |-------|-----------| | `RAILS_ENV` | `APP_ENV` = `development` \| `test` \| `production` | | `config/environments/*.rb` | One `match config.env` per concern, inside `Config::from_env` or the kernel module that cares | | `database.yml` | `DATABASE_URL` (+ `DATABASE_POOL`) | | `credentials.yml.enc` + master key | Env vars, set through the deploy platform's secret store | | `Rails.application.config.x.foo` | A typed field on `Config` | | `.env` (dotenv-rails) | `dotenvy`, loaded **only** in development | ## `src/config.rs` ```rust /// The human product name: page titles, kernel UIs (/dev, /admin/jobs), the default MAIL_FROM. /// Never derived from the crate name (env!("CARGO_PKG_NAME")): crates get renamed, products don't. pub const APP_NAME: &str = "Bookshelf"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppEnv { Development, Test, Production } #[derive(Debug, Clone)] pub struct Config { pub env: AppEnv, pub database_url: String, pub database_pool: u32, // DATABASE_POOL, default 4 on SQLite, 10 on Postgres pub worker_threads: usize, // APP_WORKER_THREADS, default 2: Tokio workers (main builds the runtime from it) pub blocking_threads: usize, // APP_BLOCKING_THREADS, default 16: cap on Tokio's blocking pool (Tokio's own: 512) pub app_url: String, // APP_URL, absolute, no trailing slash: used in emails and the origin check pub port: u16, // PORT, default 3000 pub inline_worker: bool, // APP_INLINE_WORKER: default true in dev, and in prod on SQLite pub queues: Vec<(String, usize)>, // APP_QUEUES, default "default:10,mailers:5" (queue:concurrency per node) pub jobs_prune_days: u32, // APP_JOBS_PRUNE_DAYS, default 7: how long finished jobs stay visible pub jobs_shutdown_grace: u64, // APP_JOBS_SHUTDOWN_GRACE seconds, default 25 pub shutdown_drain: Duration, // APP_SHUTDOWN_DRAIN_SECS, default 5: after the signal, open connections get this long, then are dropped (realtime.md) pub refresh_wait: Duration, // not an env var: 5 s (Config::test(): 100 ms); how long a live re-fetch waits at the gate before 503 (realtime.md) pub admin_password: Option, // APP_ADMIN_PASSWORD: HTTP Basic for /admin/jobs when the app has no admin role pub smtp_url: Option, // SMTP_URL: production delivery (ignored in development unless MAILER=smtp) pub mailer: Option, // MAILER = smtp | local | log: overrides the per-environment default (mailers.md) pub mail_from: String, // MAIL_FROM, default " " pub trusted_proxy: bool, // APP_TRUSTED_PROXY: read the client IP from X-Forwarded-For (required behind any proxy; rate limiting) pub trusted_proxy_hops: u8, // APP_TRUSTED_PROXY_HOPS (default 1): how many proxies append to the header; the client is that many hops from the right pub log_format: LogFormat, // LOG_FORMAT = pretty | json (default: json in production) pub revision: Option, // APP_REVISION: git sha of the build, shown on /dev/system pub tls: Option, // APP_TLS_CERT + APP_TLS_KEY (PEM paths): `serve` speaks HTTPS + h2 itself (deployment.md#direct-tls-optional); both or neither, one alone fails the boot. pub dev_http_port: Option, // APP_DEV_HTTP_PORT: development only (any other APP_ENV fails the boot); with TLS on, the same router on plain HTTP beside it (dev-tools.md#https-in-development) // The development APP_URL default follows it: https://localhost:{PORT} when set (CSRF compares origins). pub bind: IpAddr, // APP_BIND: the address `serve` listens on. Default 127.0.0.1 in development (this machine only), 0.0.0.0 otherwise (security.md#development-is-this-machine-only) pub allowed_hosts: Vec, // APP_ALLOWED_HOSTS: extra Host names development answers (a tunnel, a LAN name); ".example.com" = its subdomains too pub env_defaulted: bool, // always false: an unset APP_ENV refuses to boot (kept for parts that read it) // apps with an API (api.md): // pub cors_origins: Vec, // APP_CORS_ORIGINS: exact origins, comma-separated; `*` fails at boot (config::parse_cors_origins) // added with the first attachment field (storage.md): // pub storage: StorageConfig, // STORAGE_SERVICE, STORAGE_PATH, STORAGE_BUCKET, STORAGE_ENDPOINT, STORAGE_REGION, STORAGE_PUBLIC_HOST // app-specific settings: APP_* prefixed, typed, documented in .env.example } impl Config { /// Called once in main. Development: `dotenvy::dotenv().ok()` first. /// Collects ALL missing/invalid vars, then fails with one message listing them. pub fn from_env() -> anyhow::Result; pub fn test() -> Self; // the test suite's config: APP_ENV=test, sqlite::memory:, never reads env pub fn is_production(&self) -> bool; pub fn is_development(&self) -> bool; pub fn boot_warnings(&self) -> Vec; // below; `main` logs each at warn right after tracing starts } /// "default:10,mailers:5" → [("default", 10), ("mailers", 5)]; also used by `app worker --queues`. pub fn parse_queues(s: &str) -> Result, String>; ``` ### Where `serve` listens (`APP_BIND`, `APP_ALLOWED_HOSTS`) and the unset-`APP_ENV` warning Development mounts `/dev` (the mailbox with every login link, introspection, the jobs dashboard) **without a login**, so it listens on this machine only, and an unset `APP_ENV` is said out loud at every boot ([security](security.md#development-is-this-machine-only)). Copy these functions and their tests into `config.rs` (from an app generated from these docs, 2026-10-16: compiled, clippy-clean, green on SQLite and Postgres): ```rust use std::net::{IpAddr, Ipv4Addr}; /// `APP_BIND`, or the environment's default: loopback in development, every interface otherwise. // SECURITY: development serves /dev tools without a login; on 0.0.0.0 anyone on the same Wi-Fi // could open them. A dev container sets APP_BIND=0.0.0.0 explicitly // (test: development_binds_loopback_unless_told_otherwise). pub fn bind_address(env: AppEnv, raw: Option<&str>) -> Result { match raw.map(str::trim).filter(|v| !v.is_empty()) { Some(v) => v.trim_start_matches('[').trim_end_matches(']').parse().map_err(|_| { format!("APP_BIND (expected an IP address such as 127.0.0.1 or 0.0.0.0, got {v:?})") }), None if env == AppEnv::Development => Ok(IpAddr::V4(Ipv4Addr::LOCALHOST)), None => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), } } /// `APP_ALLOWED_HOSTS`: comma-separated host names, lowercased. pub fn parse_allowed_hosts(raw: Option<&str>) -> Vec { raw.unwrap_or_default() .split(',') .map(|h| h.trim().to_ascii_lowercase()) .filter(|h| !h.is_empty()) .collect() } impl Config { /// Loud warnings `main` logs right after logging starts (parts add theirs here). pub fn boot_warnings(&self) -> Vec { Vec::new() } } ``` `APP_ENV` is **required**. `from_env` reads it; when it is unset it loads `.env` (bin/setup writes `APP_ENV=development` there) and reads again; still unset is an error naming the fix, never a default: ```rust // SECURITY: an unset APP_ENV is an error, never a default. Development mounts /dev tools // without a login, so "forgot to set it on a server" must not silently become development // (test: an_unset_app_env_refuses_to_boot). if get_env().is_none() { dotenvy::dotenv().ok(); } let env = match get_env().as_deref() { Some("development") => AppEnv::Development, Some("test") => AppEnv::Test, Some("production") => AppEnv::Production, Some(other) => anyhow::bail!("invalid APP_ENV: {other} (development | test | production)"), None => anyhow::bail!("APP_ENV is not set. Locally: APP_ENV=development in .env (bin/setup creates it \ from .env.example). On every server: APP_ENV=production (the Dockerfile sets it)."), }; // … with the other settings; a bad value joins the one "invalid env vars" message: let bind = bind_address(env, get("APP_BIND").as_deref()).unwrap_or_else(|problem| { invalid.push(problem); IpAddr::V4(Ipv4Addr::LOCALHOST) }); let allowed_hosts = parse_allowed_hosts(get("APP_ALLOWED_HOSTS").as_deref()); ``` `Config::test()` sets `bind: IpAddr::V4(Ipv4Addr::LOCALHOST)`, `allowed_hosts: Vec::new()`, `env_defaulted: false`; the hand-written `Debug` prints all three (none is a secret). `main` runs `for w in config.boot_warnings() { tracing::warn!("{w}"); }` right after `init_tracing` (`app routes` returns before `from_env`: the route table needs no config), and `serve` binds `SocketAddr::new(config.bind, config.port)` (and `APP_DEV_HTTP_PORT` on the same `config.bind`). The boot test lives in `tests/app/kernel_test.rs` (an integration test, where `CARGO_BIN_EXE_offrails_app` exists), the rest in `config.rs`: ```rust /// Runs the binary in a directory with no `.env`, so the fallback has nothing to load. #[test] fn an_unset_app_env_refuses_to_boot() { let dir = tempfile::tempdir().unwrap(); let out = std::process::Command::new(env!("CARGO_BIN_EXE_offrails_app")) .arg("db").arg("status").env_remove("APP_ENV").current_dir(dir.path()).output().unwrap(); assert!(!out.status.success()); assert!(String::from_utf8_lossy(&out.stderr).contains("APP_ENV is not set")); } #[test] fn development_binds_loopback_unless_told_otherwise() { let (local, any) = (IpAddr::V4(Ipv4Addr::LOCALHOST), IpAddr::V4(Ipv4Addr::UNSPECIFIED)); assert_eq!(bind_address(AppEnv::Development, None), Ok(local)); assert_eq!(bind_address(AppEnv::Development, Some("")), Ok(local)); assert_eq!(bind_address(AppEnv::Development, Some("0.0.0.0")), Ok(any)); // a container opts in assert_eq!(bind_address(AppEnv::Development, Some("[::1]")), Ok("::1".parse().unwrap())); assert_eq!(bind_address(AppEnv::Production, None), Ok(any)); // behind the platform's proxy assert!(bind_address(AppEnv::Development, Some("localhost")).is_err()); assert_eq!(parse_allowed_hosts(Some(" Dev.Example.com, .tunnel.example ,")), ["dev.example.com", ".tunnel.example"]); } ``` - `Config` holds credentials, so implement `Debug` by hand **as an allowlist**: print the value only for settings that can never hold a secret (`env`, `port`, `app_url`, `database_pool`, `queues`, the numbers, durations and booleans, `bind`, `allowed_hosts`, `log_format`, `revision`, `mail_from`), the database as its backend and host only (`redact_db`), and `set` / `not set` for everything else, `smtp_url`, `admin_password` and every field added later included, until you add it to the allowlist on purpose. A denylist (`[redacted]` for three named fields) leaks the next secret someone adds (`STRIPE_SECRET_KEY`, a webhook signing key) on `/dev/introspect.json` and `app introspect`. Test `debug_shows_no_secret_values`: a `Config` with a sentinel (`"sentinel-secret"`) in every string field outside the allowlist, and `format!("{c:?}")` contains no sentinel. - **Env var names are string literals in `config.rs`** (`var("APP_URL")`, `parse("PORT", "3000")`, never a name built with `format!`), and every `"SCREAMING_SNAKE"` literal there is an env var: `offrails-check`'s `[env]` rule reads them all and fails on any missing from `.env.example`. A literal that isn't one gets a trailing `// not an env var`. - **Read env vars only in `config.rs`.** Nowhere else calls `std::env::var` (the AWS keys read by `object_store`'s `from_env()` are the one sanctioned exception). - `Config` lives in `AppState` (`state.config`), wrapped in `Arc` if it gets large. - **Fail fast in production.** Missing `DATABASE_URL`, `APP_URL`, or `SMTP_URL` (when email is enabled) aborts boot with: `missing required env vars: APP_URL, SMTP_URL (see .env.example)`. - Development has defaults for everything except `APP_ENV` itself, so `cargo run` works once `.env` says `APP_ENV=development` (`bin/setup` writes it): - `DATABASE_URL=sqlite://storage/development.sqlite3?mode=rwc` - `APP_URL=http://localhost:{PORT}`: it follows `PORT` (`config::dev_app_url(port)`), so emailed links and the CSRF origin check still work when you run on another port. Parse `PORT` before `APP_URL`. - `APP_ENV` unset **refuses to boot** with a message naming the fix (above): on a server an unset `APP_ENV` is a missing setting that would mount `/dev` and `/admin/jobs` without a login, and a default is not a gate. The Dockerfile sets `APP_ENV=production`, and so must every deploy config. `TestApp` builds `Config` directly (`Config::test()`) and never reads the environment. - `serve` listens on `APP_BIND`: **127.0.0.1 in development**, 0.0.0.0 in production (the container's interface, behind the platform's proxy). A dev container, or a phone on your LAN, needs `APP_BIND=0.0.0.0` **and** its host name in `APP_ALLOWED_HOSTS` ([security](security.md#development-is-this-machine-only)). ### Runtime threads (`APP_WORKER_THREADS`, `APP_BLOCKING_THREADS`) `main` parses `Config` first, then builds the Tokio runtime from it (no `#[tokio::main]`): 2 worker threads and at most 16 blocking threads by default, instead of one worker per core and 512 blocking threads. Each idle thread holds a stack and an allocator arena, so a small app on a many-core box stays small. Raise `APP_WORKER_THREADS` when request handlers do real CPU work (rendering big pages, image processing) on a box with cores to spare, and `APP_BLOCKING_THREADS` when `spawn_blocking` work (password hashing, file I/O) queues under load. I/O-bound apps rarely gain past 2–4 workers. The file is [`template/app/src/main.rs`](../template/app/src/main.rs); [database](database.md#sqlite-memory) has the SQLite side and the measured numbers. ## Per-environment behavior Put it in one place, as a `match`: ```rust // src/state.rs: select_mailer(&config, &db). The kernel only has constructors (Mailer::smtp/local/memory, Mailer::Log). Ok(match (c.env, c.mailer.as_deref()) { (AppEnv::Test, _) => Mailer::memory(), (_, Some("smtp")) => smtp(from)?, // needs SMTP_URL: try real delivery from development (_, Some("local")) => Mailer::local(db.clone(), from), (_, Some("log")) => Mailer::Log, (_, Some(other)) => anyhow::bail!("MAILER must be smtp, local or log (got {other:?})"), (AppEnv::Development, None) => Mailer::local(db.clone(), from), // ALWAYS the dev mailbox, even with SMTP_URL set (AppEnv::Production, None) if c.smtp_url.is_some() => smtp(from)?, (AppEnv::Production, None) => Mailer::Log, // EMAIL: not yet (logged only) }) ``` **Rate limits are per environment too** ([security](security.md#rate-limiting-kernelrate_limitrs)): in development every limit is **1000 per window** (`rate_limit::DEV_LIMIT`), so a person or agent retrying a form locally never locks themselves out; test and production use each call site's own limit (the 11th login is refused in tests exactly as in production). `RateLimiter::new(config.env)` decides it, once; call sites never branch on the environment. **Never use `cfg!(debug_assertions)` or `#[cfg(debug_assertions)]` for behavior.** A release build running locally must behave like development when `APP_ENV=development`. ## `.env.example` This file is the documentation for every variable. One line each, grouped, with a comment: ```sh # --- core --- # Required. development | test | production; an unset APP_ENV refuses to boot (development mounts /dev with no login). APP_ENV=development PORT=3000 # APP_URL=https://myapp.example.com # required in production; in development it follows PORT (http://localhost:{PORT}), so leave it unset DATABASE_URL=sqlite://storage/development.sqlite3?mode=rwc # DATABASE_URL=postgres://postgres:postgres@localhost:5432/myapp_development DATABASE_POOL=10 # APP_BIND=0.0.0.0 # development listens on 127.0.0.1 only (its /dev tools have no login); a dev container opts in here. Production default: 0.0.0.0 # APP_ALLOWED_HOSTS=dev.example.com,.lan.example # development answers only localhost and APP_URL's host (DNS rebinding); add a tunnel or LAN name ('.' = subdomains too) APP_INLINE_WORKER=true # APP_TRUSTED_PROXY=true # behind Fly, Render or any proxy: read the client IP from X-Forwarded-For (REQUIRED behind any proxy, or every user shares one rate-limit key) # APP_TRUSTED_PROXY_HOPS=1 # proxies that append to X-Forwarded-For before the app: 1 = one platform proxy (Fly, Render), 2 = Cloudflare → Caddy → app # --- realtime (channels.md) --- # APP_NODE=web-1 # this process's name on /dev/sockets and in the cluster (default :) # APP_CLUSTER=true # Postgres only: share rooms and presence across nodes; refused at boot on SQLite # APP_REALTIME_REVALIDATE_SECS=60 # long-lived sockets/streams re-check their session this often (logout closes them at once) # APP_REALTIME_TRANSPORTS=websocket,sse,longpoll # drop `websocket` behind a proxy that mishandles it; clients fall back # --- jobs --- APP_QUEUES=default:10,mailers:5 APP_JOBS_PRUNE_DAYS=7 APP_JOBS_SHUTDOWN_GRACE=25 # APP_SHUTDOWN_DRAIN_SECS=5 # web: open connections get this long after SIGTERM (streams end first); keep it below the platform's kill timeout # APP_ADMIN_PASSWORD= # production: protects /admin/jobs when there's no admin role # --- email: development always uses the dev mailbox (/dev/mailbox); SMTP_URL is for production --- # SMTP_URL=smtps://user:pass@smtp.provider.com:465 # MAILER=smtp # only to try real delivery from development (needs SMTP_URL) MAIL_FROM="Myapp " # --- storage (added with the first attachment field) --- # STORAGE_SERVICE=local # STORAGE_PATH=storage/files # STORAGE_PUBLIC_HOST= # only when serving S3 presigned URLs (storage.md) # APP_REVISION= # git sha, set by the deploy (shown on /dev/system) # --- API (apps with an API; guides/api.md) --- # APP_CORS_ORIGINS=https://app.example,http://localhost:5173 # browser apps on other origins; exact, never * # --- direct TLS (optional; default: plain HTTP behind the platform's TLS proxy) --- # APP_TLS_CERT=/etc/app/tls/cert.pem # APP_TLS_KEY=/etc/app/tls/key.pem # APP_DEV_HTTP_PORT=3001 # development only, with TLS on: the same app on plain http (bin/dev sets it with DEV_CERT_SELF_SIGNED=1) # --- logging --- RUST_LOG=info,myapp=debug LOG_FORMAT=pretty ``` `bin/setup` copies it to `.env` if `.env` is missing. `.env` is git-ignored, always. ## Secrets in production There's no encrypted credentials file. Secrets come from the platform: | Target | How | |--------|-----| | Fly.io | `fly secrets set SMTP_URL=… ` | | Kamal | `.kamal/secrets` (reads from a password manager or env; never commit raw values) + `env.secret` in `config/deploy.yml` | | Railway / Render | Service variables in the dashboard or CLI | When you add an env var: update `Config`, `.env.example`, and the deploy config. **Then tell the human which secret to set and where.** You can't set platform secrets for them unless they've given you CLI access. ## Gotchas agents get wrong - Reading `std::env::var("X")` deep inside a handler. Add a `Config` field instead. - Calling `dotenvy::dotenv()` when `APP_ENV` is already set, so a stray `.env` in a production image overrides real config. Only call it when `APP_ENV` is unset (to find it) or `development`. - Trailing slash on `APP_URL`, which produces `//` in email links. Normalize it in `from_env`. - Committing real values to `.env.example`. Only dev defaults and placeholders go there. - Deriving the product name from `env!("CARGO_PKG_NAME")`. Renaming the crate (for example, to avoid a binary-name clash with another app on the same machine) then renames the product in every page title. Use `config::APP_NAME`. ================================================================ FILE: guides/controllers.md (https://rustoffrails.org/docs/guides/controllers.md) ================================================================ # Controllers (Action Controller → axum handlers) > **TL;DR** > - One file per resource with the seven Rails actions; handlers are thin: extract → authorize → model → render or redirect. > - Extractor order: `State`, auth/`Ctx`, `Path`/`Query`, then the body (`Form`/`Json`/`Multipart`) last; return `Result`. > - Successful POST → `Redirect::to` (303); validation failure → re-render with 422, keeping the params. > - Authorize before any write; scope private records by owner (another user's id is 404); no DB calls in views; side effects belong in model fns. > > Read the rest when writing handlers, flash, extractors, or JSON endpoints. A controller is one file in `src/controllers/.rs` holding plain async functions, one per action. Handlers are thin: extract → authorize → call the model → render or redirect. Business rules live in models, never here. ## The seven actions (`htmx` mode) ```rust use axum::{extract::{Path, State}, http::StatusCode, response::{IntoResponse, Redirect, Response}, Form}; use crate::{ controllers::application::RequireUser, error::AppError, kernel::flash::Flash, models::{post, Page, PostParams}, policies, routes::paths, state::AppState, views::{self, Ctx, posts::*}, }; pub async fn index(State(s): State, ctx: Ctx, page: Page) -> Result { let posts = post::published(&s.db, page).await?; Ok(views::render(PostsIndex { ctx, posts })?.into_response()) } pub async fn show(State(s): State, ctx: Ctx, Path(id): Path) -> Result { let post = post::find(&s.db, id).await?; Ok(views::render(PostsShow { ctx, post })?.into_response()) } pub async fn new(ctx: Ctx, RequireUser(_user): RequireUser) -> Result { Ok(views::render(PostsNew { ctx, params: PostParams::default(), errors: Default::default() })?.into_response()) } pub async fn create( State(s): State, ctx: Ctx, RequireUser(user): RequireUser, Form(params): Form, // body extractor LAST ) -> Result { let result = match params.validate() { Ok(new) => post::create(&s.db, user.id, new).await, Err(errors) => Err(AppError::Invalid(errors)), }; match result { Ok(post) => Ok((Flash::notice("Post was successfully created."), Redirect::to(&paths::post(post.id))).into_response()), Err(AppError::Invalid(errors)) => Ok(( StatusCode::UNPROCESSABLE_ENTITY, views::render(PostsNew { ctx, params, errors })?, ).into_response()), Err(e) => Err(e), } } pub async fn edit(State(s): State, ctx: Ctx, RequireUser(user): RequireUser, Path(id): Path) -> Result { let post = post::find(&s.db, id).await?; policies::authorize(policies::posts::can_edit(&user, &post))?; // other users: 403 (auth.md#authorization) let params = PostParams::from(&post); // prefill Ok(views::render(PostsEdit { ctx, post, params, errors: Default::default() })?.into_response()) } // update: same shape as create (find → authorize → validate → post::update → redirect / 422) // destroy: find → authorize → post::destroy → Flash::notice + Redirect::to(&paths::posts()) ``` ### Other handler shapes - **`inertia` mode:** the same seven actions, but `inertia.render("posts/Index", props)` instead of `views::render`. Failed validation **redirects back** with errors (`Json`/`Form` + `map_err(AppError::Invalid)`); there's no 422 re-render. See [frontend-inertia: the adapter's Rust surface](frontend-inertia.md#the-adapters-rust-surface) and [validation errors: redirect back](frontend-inertia.md#validation-errors-redirect-back). - **`spa` mode:** [JSON API](#json-api) below. - **Uploads:** `multipart: Multipart` as the last argument, then `storage::read_multipart::(multipart)` and `form.take_file("cover")`. Validate the params and the file together ([storage](storage.md#uploading-controller)). - **Nested resources** (`POST /books/{book_id}/reviews`): load the parent first (404 if missing) and authorize against it, then create. On a validation failure, re-render the **parent's** page with the child form's errors. Build that page in one shared function (`books::show_page(&s, ctx, &user, id, form)`) so `show` and the failed create can't drift apart. - **htmx fragment routes** (a live region's refresh, `GET /books/{book_id}/reviews`): take `HxRequest(htmx)`. Return the partial when it's true; otherwise redirect to the parent page. ## Rules 1. **Signature order:** `State`, then `Ctx`/auth extractors, then `Path`/`Query`, then the body extractor (`Form`, `Json`, `Multipart`) **last**. axum rejects anything else at compile time with an unreadable error. 2. **Every handler returns `Result`.** Let `?` do the work. Never `unwrap`. 3. **Redirect after every successful POST** with `Redirect::to` (303 See Other). Never render a page from a successful POST. 4. **On validation failure, re-render the form with status 422,** passing back the original `params` (so input is kept) and the `errors`. 5. **Authorization comes before any write**, and before rendering anything not public. See `auth.md#authorization`. 6. **Scope by owner in the query** when a resource is private: `post::find_for_user(&db, user.id, id)` returns `NotFound` for other users' rows. That beats find-then-check because it never leaks existence. 7. **No DB calls in views.** Load everything the view needs in the handler and pass it in. 8. **Side effects belong in the model fn** (enqueue jobs there, in the transaction), not in the handler. 9. **Every action is `#[tracing::instrument(skip_all)]`**, so traces name it (a helper it calls in a loop: `level = "debug"`; [the one rule](errors-and-logging.md#spans)); never put secrets or personal data in span fields. ## Rails → Off Rails | Rails | Off Rails | |-------|-----------| | `before_action :authenticate_user!` | `RequireUser(user): RequireUser` argument | | `before_action :set_post` | `let post = post::find(&s.db, id).await?;` as the first line. Repetition is fine | | `params.require(:post).permit(:title, :body)` | `Form`. The struct is the permit list | | `redirect_to @post, notice: "…"` | `(Flash::notice("…"), Redirect::to(&paths::post(id)))` | | `render :new, status: :unprocessable_entity` | `(StatusCode::UNPROCESSABLE_ENTITY, views::render(PostsNew { … })?)` | | `head :no_content` | `StatusCode::NO_CONTENT` | | `rescue_from ActiveRecord::RecordNotFound` | Automatic: `DbErr::RecordNotFound` / `None` → `AppError::NotFound` → 404 | | `helper_method :current_user` | `Ctx` carries `current_user` into every view | | `request.xhr?` / `turbo_frame_request?` | `HxRequest(is_htmx): HxRequest` | | `ApplicationController` | `controllers/application.rs`: shared extractors only, no inheritance | ## Shared extractors (`controllers/application.rs`) | Extractor | Yields | Rejection | |-----------|--------|-----------| | `CurrentUser(Option)` | the logged-in user, if any (spec in `auth.md`) | never rejects | | `RequireUser(User)` | the logged-in user | `AppError::Unauthorized` | | `CurrentSession(session::Model)` | the session row, for logout | `AppError::Unauthorized` | | `UserAgent(Option)` | the `User-Agent` header, stored on new sessions | never rejects | | `Ctx` | `views::Ctx { title, flash: Option, current_user: Option, assets, dev, dev_mailbox }` for layouts (`htmx`). `current_user` and `dev_mailbox` arrive with the authentication generator. The struct lives in `views/mod.rs`, the `FromRequestParts` impl here | `AppError` (only on DB errors loading the session) | | `HxRequest(bool)` | `true` when `HX-Request: true` | never rejects | | `Inertia` | the request's Inertia context (`frontend-inertia.md`) | never rejects | | `Page` | `?page=&per=` pagination (`models/mod.rs`, implemented via `Query`) | `AppError::Invalid` (422) on garbage | | `AdminAccess` | access to `/admin/*`: open in development, `RequireAdmin` when the app has roles, otherwise HTTP Basic with `APP_ADMIN_PASSWORD` | 401 + `WWW-Authenticate: Basic` | | `ClientIp` | the client IP for rate limiting (`kernel/rate_limit.rs`) | never rejects | The session lookup behind `Ctx`, `CurrentUser`, `RequireUser` and `CurrentSession` runs once per request and is cached in the request extensions ([auth](auth.md#sessions)). Implement them with `FromRequestParts`. **axum 0.8 uses native `async fn` in the trait, so don't add `#[async_trait]`.** Cache expensive lookups (the current user) in `parts.extensions` so `Ctx`, `CurrentUser` and `RequireUser` in one handler hit the DB once. ## Flash (`kernel/flash.rs`) One type, `kernel::flash::Flash { kind: FlashKind, message: String }`, where `FlashKind` is `Notice` or `Alert` and serializes in snake_case. Inertia/spa modes also derive `ts_rs::TS` on it. - **Storage:** the `_flash` cookie holds the JSON, **base64url-encoded without padding** (cookie values can't contain `"`, `,` or `;`). Its attributes are `Path=/`, `HttpOnly`, `SameSite=Lax`. It isn't signed, because the value is only ever rendered escaped. - **Setting:** `Flash::notice("…")` / `Flash::alert("…")` implement `IntoResponseParts`, which appends `Set-Cookie`. Put the flash in the response tuple before the body or redirect: `(Flash::notice("Saved"), Redirect::to(&path))`. - **Middleware** (`from_fn(kernel::flash::middleware)`): - On the request, decode `_flash` into a `Flash` request extension. - On the response, append a removal cookie (`make_removal()`) only if the request carried a flash, the response sets no new `_flash`, and the response **isn't a redirect**. This gives read-once semantics, as in Rails. - Clearing on a redirect would lose a flash set two hops earlier. - **Reading:** `Ctx.flash` (`parts.extensions.get::().cloned()`). - **Rendering:** always escaped (Askama default, or React text). Never `|safe`. ## JSON API `spa` mode, and JSON endpoints for islands. This is the *internal* API your own UI calls (cookie session, types via ts-rs, no versioning). A **public** API for other programs (`API: rest | graphql | both`, or `FRONTEND_MODE: api`) follows [api](api.md) instead: `/api/v1`, bearer tokens, `ApiJson`, OpenAPI, `{"data": …}` envelopes. Handlers live in `src/controllers/api/.rs` and are mounted under `/api` with real verbs: | Action | Route | |--------|-------| | index | `GET /api/posts` | | create | `POST /api/posts` | | show | `GET /api/posts/{id}` | | update | `PATCH /api/posts/{id}` | | destroy | `DELETE /api/posts/{id}` | ```rust pub async fn create(State(s): State, RequireUser(user): RequireUser, Json(params): Json) -> Result<(StatusCode, Json), AppError> { let new = params.validate().map_err(AppError::Invalid)?; let post = post::create(&s.db, user.id, new).await?; Ok((StatusCode::CREATED, Json(PostJson::from(&post)))) } ``` - Response bodies are `views::Json` / `Page` structs deriving `Serialize` + `ts_rs::TS`. **Never serialize an entity `Model` directly**, because new columns (and password digests) would leak. - Errors are JSON on `/api` paths: `AppError::Invalid` → `422 {"errors": {"title": ["can't be blank"]}}`, `Unauthorized` → `401 {"error":"unauthorized"}`, `NotFound` → `404`, `Forbidden` → `403`. `error.rs` picks the format from the `ResponseFlavor` request extension (Api / Inertia / Htmx / Html), which one middleware sets; see `errors-and-logging.md`. - Lists return `{ "items": [...], "page": n, "per": n }`. - Auth is the same cookie session as HTML (`auth.md`). Tokens belong to the public API ([api](api.md#authentication-api-tokens)): set `API` in the app's header rather than adding tokens here. ## Gotchas agents get wrong - Putting `Form(...)` before `Path(...)` → "the trait `Handler` is not implemented". Move the body extractor last. - `Redirect::permanent` after a POST. Use `Redirect::to` (303). - Returning 200 with errors from a failed form. It must be 422, or htmx won't treat it as an error and tests can't tell. - Forgetting `RequireUser` on `new`/`edit` while protecting only `create`/`update`. - Re-querying the current user in the handler. Use the extractor. - `#[async_trait]` on `FromRequestParts` impls. That was axum 0.7; it's gone in 0.8. ================================================================ FILE: guides/database.md (https://rustoffrails.org/docs/guides/database.md) ================================================================ # Database: one codebase, SQLite and Postgres > **TL;DR** > - `DATABASE_URL` picks SQLite or Postgres at runtime; both drivers are always compiled in; tests run on both. > - Everything goes through SeaORM; backend-specific SQL only in `src/kernel/` or behind an explicit backend `match`. > - SQLite: WAL, `foreign_keys = ON`, `busy_timeout`; in-memory test DBs use one connection. > - Write transactions on SQLite start with `kernel::db::begin` (`BEGIN IMMEDIATE`), or they fail with "database is locked" when the job worker writes. > > Read the rest when touching connections, the kernel's db module, portability, or moving data between backends. `DATABASE_URL`'s scheme picks the backend at runtime. Nothing is recompiled. Both drivers are always compiled in. | `DATABASE_URL` | Backend | |----------------|---------| | `sqlite://storage/development.sqlite3?mode=rwc` | SQLite file (dev default; `storage/` is git-ignored) | | `sqlite::memory:` | SQLite in-memory (tests) | | `postgres://user:pass@host:5432/db` | Postgres | ## Which backend where - **Development:** SQLite. There's nothing to install and `bin/setup` just works. Switch to Postgres locally with `docker compose up -d db` and the Postgres URL in `.env` when debugging a Postgres-only issue. - **Test:** SQLite in-memory by default (fast, isolated). `TEST_DATABASE_URL=postgres://…` runs the whole suite on Postgres. **CI runs both**, and both must pass. - **Production:** Postgres (managed, from the deploy platform). Single-server apps may run SQLite in production, as Rails 8 does, if the [interview](../interview.md) chose it: the DB lives on a persistent volume and is backed up (Litestream or the platform's volume snapshots). ## `kernel/db.rs` spec `pub async fn connect(url: &str, max_connections: u32) -> Result`. One path for both backends: `sea_orm::Database::connect(ConnectOptions)`. **The file is [`template/app/src/kernel/db.rs`](../template/app/src/kernel/db.rs)** (in an app: `src/kernel/db.rs`, from `.offrails/template/app/`), verified with SeaORM 2.0.4 / sqlx 0.9. What it does and why: - SQLite: `create_if_missing`, `foreign_keys(true)` (critical: SQLite ignores FKs otherwise), `busy_timeout(5 s)`, `synchronous(Normal)`, WAL for file databases, a 1 MiB `cache_size` per connection, idle connections closed after 60 s, and a single connection for in-memory databases (each connection to `:memory:` is a different, empty database). - `sea_orm::sqlx` is the re-export; never a separate sqlx dependency. - `begin` is the only way a transaction starts: ```rust /// Every transaction starts here, never with `db.begin()`. pub async fn begin(db: &C) -> Result { db.begin_with_options(TransactionOptions { sqlite_transaction_mode: Some(SqliteTransactionMode::Immediate), ..Default::default() }) .await } ``` - **Transactions are `BEGIN IMMEDIATE` on SQLite** (`kernel::db::begin`; Postgres ignores the mode, and on a transaction it nests as a savepoint). A plain `db.begin()` is deferred: it reads first, and when it then writes after another connection wrote in between (the job worker's heartbeat writes every few seconds), SQLite fails it with `SQLITE_BUSY_SNAPSHOT` ("database is locked", code 517). `busy_timeout` can't retry that. Rails 8 makes the same choice. The in-memory test database has a single connection and can't show the bug, so a kernel test opens a **file** database with a pool of 4, reads in a transaction, writes from another connection, then writes in the transaction. It fails with `db.begin()` and passes with `kernel::db::begin`. - `max_connections` comes from `DATABASE_POOL` (default 4 on SQLite, 10 on Postgres). **Always set it:** SeaORM defaults SQLite pools to 1 connection. - **`opts.acquire_timeout(Duration::from_secs(5))`** (sea-orm 2.0.4 `ConnectOptions::acquire_timeout`; sqlx's default is 30 s). A request that can't get a connection in 5 s fails with `DbErr::ConnectionAcquire(ConnAcquireErr::Timeout)`, which `AppError` maps to **503 + `Retry-After`** ([errors](errors-and-logging.md#response-mapping)), instead of hanging until the request timeout. Live re-fetches are additionally capped to a third of the pool ([realtime](realtime.md#keep-re-fetches-from-starving-user-requests)). - The pragmas are applied to every pooled connection (`map_sqlx_sqlite_opts`), not once per pool. - `TestApp` calls `connect` and then `db.set_metric_callback(...)` to count queries. The callback needs `&mut`, so set it before the connection is cloned into `AppState`. - A test asserts `PRAGMA foreign_keys` returns 1 (`query_one_raw(Statement::from_string(DbBackend::Sqlite, "PRAGMA foreign_keys"))`). ## SQLite memory Small apps are lean by default ([template](../template/README.md#layout), memory): - **`cache_size` is 1 MiB per connection** (`PRAGMA cache_size = -1024`; SQLite's default is 2 MiB). Each pooled connection keeps its own page cache, so the pool size multiplies it. The OS page cache still holds the database file, so a small app loses little. Raise it (e.g. `-8192` for 8 MiB) when a read-heavy app's database outgrows the cache and `/dev/dashboard` or `EXPLAIN` shows the same pages read again and again. - **The pool is 4 connections on SQLite** (`DATABASE_POOL`; 10 on Postgres). SQLite has one writer at a time, and WAL readers rarely need more. Idle connections close after 60 s, so a burst's extra caches don't stay. - Postgres is unaffected: its memory is the server's, and `DATABASE_POOL` sizes the client pool. - Measured on the bare template (release, macOS, 1000 requests): 15 MiB RSS ([`tools/ci/memory-budget.sh`](../tools/ci/memory-budget.sh)). Runtime threads are the other lever: `APP_WORKER_THREADS` and `APP_BLOCKING_THREADS` ([config](config.md#srcconfigrs)). ## Portability rules 1. **All app queries go through SeaORM's builder.** It quotes, parameterizes, and emits the right dialect. 2. **Use only portable types:** `i64`, `i32`, `bool`, `String`, `DateTimeUtc`, `Date`, `Uuid`, `Json` (opaque), `Vec`. No arrays, no `JSONB` operators, no `INET`, no native enums, no `NUMERIC` money. 3. **No DB-side logic:** no triggers, stored procedures, or views. Defaults and timestamps are set in Rust. 4. **Case-insensitive matching:** lowercase in Rust on write, compare lowercased on read. Never `ILIKE`. 5. **Ordering is explicit.** Always `order_by` a unique column last (`id`). Backends order ties differently. 6. **No reliance on error message text.** Use `DbErr::sql_err()` variants. 7. **Booleans and timestamps round-trip** through SeaORM correctly on both backends. Don't compare them with raw SQL literals. ## Escape hatch When a spec needs something the builder can't express portably (full-text search, `SKIP LOCKED`, upserts with complex conflict targets): ```rust match db.get_database_backend() { DbBackend::Postgres => { let stmt = Statement::from_sql_and_values(DbBackend::Postgres, "SELECT … WHERE x = $1", [value.into()]); db.query_all_raw(stmt).await? // SeaORM 2: *_raw for Statements (execute_raw, query_one_raw, query_all_raw) } DbBackend::Sqlite => { /* SQLite SQL with `?` binds, or a simpler portable fallback */ } _ => unreachable!("only SQLite and Postgres are supported"), } ``` - Both branches are required, and both are tested (the CI matrix covers it). - Values are always bound (`$1` for Postgres, `?` for SQLite). Never `format!` values into SQL. - Log every escape-hatch use in `docs/decisions.md`. - Full-text search: Postgres `tsvector` generated column + GIN index; SQLite FTS5 virtual table kept in sync by the model fn that writes the row (not by triggers). ## Kernel features per backend | Kernel | SQLite | Postgres | |--------|--------|----------| | jobs | Portable optimistic claim (`UPDATE … SET state = 'executing' WHERE id = ? AND state = 'available'`, check rows affected) | Same; may switch to `FOR UPDATE SKIP LOCKED` behind the escape hatch at scale | | cache | `cache_entries` table | same | | pubsub | `broadcasts` table polling | same (optionally woken by `LISTEN/NOTIFY`) | | sessions | `sessions` table | same | ## Moving data between backends Not automated. Schemas are identical, so for a one-time move (SQLite prod → Postgres), write an `app task copy_db ` that streams each table in FK order with keyset pagination. Log it in decisions. ================================================================ FILE: guides/deployment.md (https://rustoffrails.org/docs/guides/deployment.md) ================================================================ # Deployment > **TL;DR** > - One Docker image runs web, worker and migrations; `bin/docker-entrypoint` runs `app db migrate`, then the command. `/up` is the health check. > - Generate config only for the chosen `DEPLOY_TARGET` (Kamal, Fly, Render, Railway); `docker compose up` runs the whole app locally. > - Schema changes are expand → migrate → contract; SQLite in production means one machine with a volume and backups. > - Before deploying: green DoD, **green CI on the exact commit**, the image builds and answers `/up`, every env var is in `.env.example` + `Config` + the deploy config. Outward-facing steps need the human's yes. > - Behind Cloudflare: turn off script injection (Web Analytics, Rocket Loader, email obfuscation) instead of loosening the CSP. Open-source apps keep the real deploy config git-ignored. > > Read the rest when shipping, choosing a target, running migrations remotely, or rolling back. One Docker image runs everything: web, worker, migrations. The deploy target was chosen in the [interview](../interview.md) (`DEPLOY_TARGET` in the app's `AGENTS.md`). **Generate config only for the chosen target.** If it's "later", generate the Dockerfile and entrypoint only, and stop there. | Rails 8 | Off Rails | |---------|-----------| | `Dockerfile` (generated by `rails new`) | `Dockerfile` (generated by `generators/new.md`) | | `bin/docker-entrypoint` → `db:prepare` | `bin/docker-entrypoint` → `app db migrate`, then `exec "$@"` | | Kamal 2 + kamal-proxy | Kamal 2 (same tool; it deploys any image) | | Thruster | Not needed (axum + compression + static cache headers) | | `/up` | `/up` | | Solid Queue in Puma | `APP_INLINE_WORKER=true` (single machine) | | `bin/jobs` role | `app worker` process/role (`--queues` per role) | | Mission Control – Jobs | `/admin/jobs`, behind `AdminAccess` ([jobs-dashboard](jobs-dashboard.md)) | ## Dockerfile shape Verified 2026-10-12 by building and running this exact file (Debian 13 "trixie" images; build and runtime stages must share the Debian release, or the binary's glibc won't match). ```dockerfile # syntax=docker/dockerfile:1 # Versions: look up the current stable Rust (`rustc --version` after `rustup update`) and the Node LTS major. ARG RUST_VERSION=1.99 ARG NODE_VERSION=24 # --- frontend (inertia / react / islands only; delete this stage in plain htmx mode) --- FROM node:${NODE_VERSION}-trixie-slim AS web RUN corepack enable pnpm WORKDIR /app/web COPY web/package.json web/pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY web/ ./ # Output: /app/web/dist plus .vite/manifest.json RUN pnpm build # --- rust --- FROM rust:${RUST_VERSION}-trixie AS build WORKDIR /app COPY . . # htmx mode only: build the CSS with the pinned standalone Tailwind (bin/setup-tailwind, see frontend-htmx.md) RUN bin/setup-tailwind && bin/tailwindcss -i assets/app.css -o static/app.css --minify RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target \ cargo build --release --locked && cp target/release/myapp /usr/local/bin/app # --- runtime --- FROM debian:trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates sqlite3 postgresql-client \ && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --uid 1000 app WORKDIR /app COPY --from=build /usr/local/bin/app /usr/local/bin/app COPY --from=build /app/static ./static # inertia / react / islands: served at /assets (hashed, immutable); kernel/assets.rs reads the manifest at boot COPY --from=web /app/web/dist ./web/dist COPY bin/docker-entrypoint /usr/local/bin/docker-entrypoint RUN mkdir -p /app/storage /data && chown -R app:app /app /data USER app ENV APP_ENV=production PORT=3000 LOG_FORMAT=json EXPOSE 3000 ENTRYPOINT ["docker-entrypoint"] CMD ["app", "serve"] ``` - **Runtime-read files need their own `COPY`.** Askama templates compile into the binary, and `static/` and `web/dist` are copied above; anything the app reads from disk at runtime (a `content/` directory, the vendored `.offrails/` a docs site renders) needs its own `COPY --from=build /app/ ./` in the runtime stage, and a test that the path config points at it. - **Comments in a Dockerfile go on their own line.** `COPY a b # note` passes `#` and `note` as extra source paths and fails the build. - `myapp` is the crate name. The binary is renamed to `app` in the image, so every command below says `app`. - `RUN corepack enable pnpm` uses `web/package.json`'s `"packageManager"` pin. The Node 24 images ship corepack; Node 25+ images don't, so there use `RUN npm install -g pnpm@`. `bin/docker-entrypoint`: ```sh #!/bin/sh set -e if [ "${SKIP_MIGRATIONS:-false}" != "true" ]; then app db migrate; fi exec "$@" ``` - Templates are compiled into the binary (Askama), and migrations are embedded. Only `static/` (served at `/static`) and `web/dist/` (Vite output; `web/dist/assets` is served at `/assets`, and the manifest must exist or boot fails in production) are copied as files. Paths are relative to `WORKDIR /app`, so keep it. - `.dockerignore`: `target/`, `web/node_modules/`, `web/dist/`, `storage/`, `.env`, `.git/`, `bin/tailwindcss` (the host binary may be macOS; the image fetches its own). - **Exactly one process migrates.** `sea-orm-migration` takes **no lock**: two processes running `app db migrate` at once (web and worker booting together, or two web replicas) race on `seaql_migrations` and fail with "relation already exists" or a duplicate row on any deploy that adds a migration. So: a platform release command (`fly.toml` `[deploy] release_command`, Render's pre-deploy command, Kamal's `pre-deploy` hook) runs `app db migrate` once, and **every** process sets `SKIP_MIGRATIONS=true`. Without a release command (compose, a single Kamal web server), only the one web container migrates on boot and every other process (`worker`, extra replicas) sets `SKIP_MIGRATIONS=true`. - **Every proxy needs `APP_TRUSTED_PROXY=true`** (and `APP_TRUSTED_PROXY_HOPS` when more than one proxy appends to `X-Forwarded-For`): Fly, Render, Railway and compose-behind-Caddy all are proxies. Without it the rate limiter keys every user on the proxy's address, and ten failed logins by anyone lock out everyone ([security](security.md#rate-limiting-kernelrate_limitrs)). - Verify the image locally before the first deploy: `docker compose up --build` (below), or for SQLite apps `docker build -t myapp . && docker run --rm -p 3000:3000 -e APP_URL=http://localhost:3000 -e DATABASE_URL='sqlite:///data/p.sqlite3?mode=rwc' myapp`, then `curl localhost:3000/up`. ## Docker compose: the whole app, locally Every app has a `compose.yaml` from `new` that runs **the production image** against Postgres, so "does it work in a container, on Postgres, with migrations on boot?" is one command, long before a deploy. `bin/dev` stays the everyday loop (native, SQLite, fast rebuilds). ```yaml # The whole app in containers, production-shaped: `docker compose up --build`, then http://localhost:3000. # Same image as the deploy (Dockerfile), Postgres, migrations on boot (bin/docker-entrypoint). # Day-to-day development is `bin/dev` (native, SQLite, fast rebuilds); this is "does it run like prod?". # # docker compose up --build app + Postgres (the worker runs inline) # docker compose --profile worker up --build + a separate worker process (APP_INLINE_WORKER=false) # docker compose --profile mail up --build + Mailpit at http://localhost:8025 (real SMTP delivery) # docker compose up -d db only Postgres, for `TEST_DATABASE_URL=… cargo nextest run` # docker compose down -v stop and delete the database volume # # Ports are overridable: APP_PORT (3000), DB_PORT (5432), MAILPIT_PORT (8025). name: myapp x-app: &app build: . image: myapp:local depends_on: db: condition: service_healthy environment: &app-env APP_ENV: production DATABASE_URL: postgres://postgres:postgres@db:5432/myapp APP_URL: http://localhost:${APP_PORT:-3000} APP_INLINE_WORKER: ${APP_INLINE_WORKER:-true} APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-change-me} # With `--profile mail`: SMTP_URL=smtp://mailpit:1025 docker compose --profile mail up SMTP_URL: ${SMTP_URL:-} RUST_LOG: ${RUST_LOG:-info} services: app: <<: *app ports: ["${APP_PORT:-3000}:3000"] healthcheck: test: ["CMD", "app", "health"] interval: 5s timeout: 5s retries: 20 start_period: 10s worker: <<: *app profiles: [worker] command: ["app", "worker"] environment: <<: *app-env SKIP_MIGRATIONS: "true" # the app service migrates depends_on: app: condition: service_healthy db: image: postgres:17 # match your production major (managed hosts commonly run 16/17); CI uses the same environment: POSTGRES_PASSWORD: postgres POSTGRES_DB: myapp ports: ["127.0.0.1:${DB_PORT:-5432}:5432"] volumes: ["pg:/var/lib/postgresql/data"] # postgres:18+ moved PGDATA: mount /var/lib/postgresql instead # Upgrading an app that already has a compose file: keep its existing volume name, or you orphan local data. healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d myapp"] interval: 2s timeout: 3s retries: 30 mailpit: image: axllent/mailpit:v1.31 profiles: [mail] ports: ["127.0.0.1:${MAILPIT_PORT:-8025}:8025"] volumes: pg: ``` - The `app` healthcheck is `app health`, a subcommand that requests `GET /up` on `127.0.0.1:$PORT` (the slim runtime image has no curl or wget). With direct TLS it only checks the port opens. - `APP_ENV=production` inside compose on purpose: it's the deploy's behavior (JSON logs, `/dev` not mounted, the admin password on `/admin/jobs`). `APP_ADMIN_PASSWORD` defaults to `change-me` here only. - Don't use `env_file: .env`: `.env` holds development values (SQLite URL, `APP_ENV=development`). Compose reads `.env` only to fill `${…}` defaults. - **A container running development needs `APP_BIND=0.0.0.0`.** Development listens on `127.0.0.1` by default ([security](security.md#development-is-this-machine-only)), which inside a container is the container itself: the published port then answers nothing. Production (this file, the Dockerfile) binds `0.0.0.0` already. A dev container or a compose override with `APP_ENV: development` sets `APP_BIND: 0.0.0.0`, publishes the port on `127.0.0.1:` only (`ports: ["127.0.0.1:3000:3000"]`, or the LAN can open `/dev`), and adds `APP_ALLOWED_HOSTS` for any name other than `localhost` it's opened by. - `mailpit` is pinned to a tag, never `latest`. Check for a current one before generating. - Verified 2026-10-13 with the `offrails-api-demo` app: `docker compose up --build` from scratch (app + db healthy, migrations on boot, REST and GraphQL on Postgres), `--profile mail` (welcome email delivered to Mailpit over SMTP), `--profile worker` (jobs claimed by the worker container's node, none inline). ## Direct TLS (optional) The default is plain HTTP inside the container and TLS at the platform's proxy (which gives the browser h2). **In development, and on any box with no proxy,** set `APP_TLS_CERT` and `APP_TLS_KEY` (PEM paths) and `serve` uses `axum-server`'s rustls acceptor: HTTPS with **h2 and http/1.1 via ALPN**, graceful shutdown through its `Handle`. ```rust let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert, &key).await?; // `listener` is the one tokio TcpListener `serve` already bound; never bind_rustls(addr) on top of it. let acceptor = axum_server::tls_rustls::RustlsAcceptor::new(cfg).acceptor(kernel::tls::HttpToHttps); axum_server::from_tcp(listener.into_std()?)?.acceptor(acceptor).handle(handle).serve(service).await?; // on SIGTERM: handle.graceful_shutdown(Some(config.shutdown_drain)), the full branch: new.md step 4.12 ``` **Plain HTTP on the TLS port** (a bare `localhost:3000` in the address bar, an `http://` bookmark) used to get TLS bytes back and show `ERR_INVALID_HTTP_RESPONSE`. `kernel/tls.rs` is an inner acceptor that peeks at the first byte: `0x16` (a TLS handshake) goes on to rustls; anything else gets `307 Temporary Redirect` to `https://` and is closed (an error from the acceptor drops the connection; axum-server ignores it). 307, never 308: browsers cache permanent redirects, which would break `APP_TLS=off` on the same port later. The peek has a 10 s timeout (rustls' handshake timeout doesn't cover it), the request head is capped at 8 KiB, the path must start with one `/`, and the Host may only hold host/port characters: The whole file, with its tests. Copy it as is; it needs no new crate (axum-server, tokio). Verified with axum-server 0.8.0 and tokio 1.53 in an app that upgraded from this spec (its four tests pass): ```rust // src/kernel/tls.rs //! Plain HTTP on the TLS port (a bare `localhost:PORT` in the address bar, an `http://` bookmark) //! gets a 307 to `https://` instead of TLS bytes (`ERR_INVALID_HTTP_RESPONSE`). An inner acceptor //! for axum-server's `RustlsAcceptor`: it peeks at the first byte; `0x16` (a TLS handshake) goes on //! to rustls, anything else is answered and closed. 307, never 308: a cached permanent redirect //! would break `APP_TLS=off` on the same port later. Spec: .offrails/guides/deployment.md#direct-tls-optional //! //! Only `serve`'s direct-TLS branch uses it (development with a trusted certificate); production //! serves plain HTTP behind its TLS proxies and never reaches this code. use std::future::Future; use std::io; use std::pin::Pin; use std::time::Duration; use axum_server::accept::Accept; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::timeout; /// How long a connection may stay silent before its first byte (rustls' handshake timeout starts /// only after this acceptor hands the stream on). pub const SNIFF_TIMEOUT: Duration = Duration::from_secs(10); /// The largest plain-HTTP request head read before answering. pub const MAX_HEAD: usize = 8 * 1024; #[derive(Clone, Copy, Debug, Default)] pub struct HttpToHttps; impl Accept for HttpToHttps { type Stream = TcpStream; type Service = S; type Future = Pin> + Send>>; fn accept(&self, stream: TcpStream, service: S) -> Self::Future { Box::pin(async move { let mut first = [0u8; 1]; let n = timeout(SNIFF_TIMEOUT, stream.peek(&mut first)) .await .map_err(|_| { io::Error::new(io::ErrorKind::TimedOut, "no bytes before timeout") })??; if n == 1 && first[0] == 0x16 { return Ok((stream, service)); } let _ = timeout(SNIFF_TIMEOUT, redirect_to_https(stream)).await; Err(io::Error::other("plain HTTP on the HTTPS port: redirected")) }) } } /// Reads the request head (up to `MAX_HEAD`), writes the 307 (or a 400 when the head has no usable /// Host or path), and shuts the connection down. async fn redirect_to_https(mut stream: TcpStream) -> io::Result<()> { let mut head = Vec::new(); let mut buf = [0u8; 1024]; while head.len() < MAX_HEAD && !head.windows(4).any(|w| w == b"\r\n\r\n") { let n = stream.read(&mut buf).await?; if n == 0 { break; } head.extend_from_slice(&buf[..n]); } let response = match redirect_target(&String::from_utf8_lossy(&head)) { Some(location) => format!( "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ), None => { "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_owned() } }; stream.write_all(response.as_bytes()).await?; stream.shutdown().await } /// The `https://` URL for a plain-HTTP request head: `https://`. `None` without a /// Host, with a Host holding anything but host/port characters, or with a path that doesn't start /// with exactly one `/` (`//evil.example` would be a protocol-relative open redirect). pub fn redirect_target(head: &str) -> Option { let mut lines = head.split("\r\n"); let mut request = lines.next()?.split(' '); let (_method, path) = (request.next()?, request.next()?); if !path.starts_with('/') || path.starts_with("//") || path.contains(['\r', '\n', ' ']) { return None; } let host = lines.take_while(|l| !l.is_empty()).find_map(|l| { let (name, value) = l.split_once(':')?; name.trim() .eq_ignore_ascii_case("host") .then(|| value.trim()) })?; let host_ok = !host.is_empty() && host .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')); host_ok.then(|| format!("https://{host}{path}")) } #[cfg(test)] mod tests { use super::*; use tokio::net::TcpListener; #[test] fn keeps_host_and_path() { assert_eq!( redirect_target("GET /books?x=1 HTTP/1.1\r\nHost: localhost:3311\r\n\r\n").as_deref(), Some("https://localhost:3311/books?x=1") ); assert_eq!( redirect_target("GET / HTTP/1.1\r\nhost: [::1]:3000\r\nAccept: */*\r\n\r\n").as_deref(), Some("https://[::1]:3000/") ); } #[test] fn refuses_open_redirects_odd_hosts_and_missing_host() { assert_eq!( redirect_target("GET //evil.example/ HTTP/1.1\r\nHost: a\r\n\r\n"), None ); assert_eq!( redirect_target("GET http://x/ HTTP/1.1\r\nHost: a\r\n\r\n"), None ); assert_eq!( redirect_target("GET / HTTP/1.1\r\nHost: evil.example/x\r\n\r\n"), None ); assert_eq!(redirect_target("GET / HTTP/1.1\r\nHost: a@b\r\n\r\n"), None); assert_eq!( redirect_target("GET / HTTP/1.1\r\nAccept: */*\r\n\r\n"), None ); assert_eq!(redirect_target(""), None); } async fn accept_one(listener: TcpListener) -> io::Result<(TcpStream, ())> { let (stream, _) = listener.accept().await?; HttpToHttps.accept(stream, ()).await } #[tokio::test] async fn answers_plain_http_with_a_307() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(accept_one(listener)); let mut client = TcpStream::connect(addr).await.unwrap(); client .write_all(b"GET /docs?q=1 HTTP/1.1\r\nHost: localhost:3000\r\n\r\n") .await .unwrap(); let mut out = String::new(); client.read_to_string(&mut out).await.unwrap(); assert!(out.starts_with("HTTP/1.1 307 "), "{out}"); assert!( out.contains("Location: https://localhost:3000/docs?q=1\r\n"), "{out}" ); assert!( server.await.unwrap().is_err(), "the connection is not handed to rustls" ); } #[tokio::test] async fn passes_a_tls_handshake_through() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(accept_one(listener)); let mut client = TcpStream::connect(addr).await.unwrap(); client.write_all(&[0x16, 0x03, 0x01]).await.unwrap(); let (mut stream, ()) = server.await.unwrap().unwrap(); let mut first = [0u8; 3]; stream.read_exact(&mut first).await.unwrap(); assert_eq!(first, [0x16, 0x03, 0x01], "nothing consumed before rustls"); } } ``` What the tests prove: `redirect_target` (pure: request head → `Option`) keeps host and path and refuses `//evil` paths, absolute-form targets, odd hosts and a missing Host; the acceptor, on a real `TcpListener`, answers a plain request with 307 and the `Location` and never hands it to rustls, and passes a `0x16` first byte through with nothing consumed. Verified with curl: `curl -si 'http://localhost:3311/books?x=1'` → `307`, `Location: https://localhost:3311/books?x=1`. - **Why it matters beyond the box:** live pages need HTTP/2 in the browser, or realtime breaks at about 6 tabs ([realtime](realtime.md#many-tabs-stall-http11-vs-http2)). `bin/dev` serves HTTPS whenever a locally trusted certificate exists, and never with an untrusted one: browsers drop cookies on it ([dev-tools](dev-tools.md#https-in-development)). - Install a crypto provider first: with sqlx/lettre in the tree, rustls can't pick one alone and panics at the first handshake. - Certificates come from your own ACME client (renew, then restart, or call `tls.reload_from_pem_file`). If you need automatic certificates, put Kamal-proxy or Caddy in front instead, and leave `APP_TLS_*` unset. - Verified 2026-10-13 with a self-signed certificate: `curl -k` negotiated h2, `--http1.1` worked, and `app health` passed. ## Workers and queues - One `app worker` role runs every queue in `APP_QUEUES` by default. Split roles only when a queue's work hurts the others (for example, slow imports): `app worker --queues imports:2` on one role, `app worker --queues default:10,mailers:5` on another. **Every queue in use must be run by some role.** `/admin/jobs` flags queues with no nodes. - Concurrency is per node, so scaling a worker role to 3 machines triples throughput. - Set the platform's stop timeout above `APP_JOBS_SHUTDOWN_GRACE` (25s): Fly `kill_timeout = 30` (plus `kill_signal = "SIGTERM"`; Fly's default is SIGINT, so the app must treat both as shutdown), Kamal `stop_timeout: 30`, Railway `drainingSeconds` (service settings), Render's default 30s grace (`maxShutdownDelaySeconds` in the service settings if you need more). Jobs still running at the deadline are rescued by Lifeline and re-run, so they must be idempotent anyway. - Set `APP_ADMIN_PASSWORD` as a secret if the app has no admin role. Otherwise `/admin/jobs` is off in production. - Inspect production jobs from your terminal with the platform's exec (below): `app jobs stats`, `app jobs list --state discarded`. ## WebSockets (channel rooms) Only for apps with channel rooms ([channels](channels.md)). SSE signals need none of this. - **Proxies:** Fly's proxy and kamal-proxy both pass WebSocket upgrades through with no extra config. The server pings every 20s (WebSocket control frames), which keeps connections alive through proxy idle timeouts (commonly 60s). *Check the current idle timeout of your proxy, and keep the ping interval below it.* A proxy or CDN that breaks WebSockets (some corporate proxies, misconfigured tunnels) doesn't break the app: clients fall back to SSE + POST, then long-polling; or set `APP_REALTIME_TRANSPORTS=sse,longpoll` to skip the attempt ([channels#transports](channels.md#transports)). - **Deploys:** on SIGTERM, `serve` calls `hub.close()`. Every socket gets `bye` `restart` and closes with **1012**; clients reconnect with backoff and rejoin with `since`, and SSE live regions replay what they missed from their cursor ([realtime#resume](realtime.md#resume)), so nothing is lost. Keep the stop timeout from the section above. The sockets close immediately, so they don't eat into it. - **More than one machine:** set `APP_CLUSTER=true` (Postgres) and give each machine a stable `APP_NODE`. On Fly, `APP_NODE=$FLY_MACHINE_ID`, which is set in every machine's environment. Kamal sets nothing like it by default, so leave it unset there (`:` is unique per container). - No sticky sessions are needed: any node can serve any socket, and the owner of a topic is computed, not routed. - (`fly-replay` could route a socket straight to its topic's owner machine to save the hop; not needed, and not implemented.) - **One machine on SQLite:** no cluster, and nothing to configure. One Rust process holds tens of thousands of sockets. - **Smoke after deploy:** open a page with a room in two browsers, post, then redeploy and change something mid-deploy. Both pages should reconnect and fill in, without a reload. ## Database choices in production | `PROD_DATABASE` | Setup | |-----------------|-------| | Postgres (default) | Managed Postgres from the platform. It provides `DATABASE_URL`. Run web and worker as separate processes | | SQLite | **Exactly one machine.** Persistent volume mounted at `/data`. `DATABASE_URL=sqlite:///data/production.sqlite3?mode=rwc`. `APP_INLINE_WORKER=true` (no separate worker). Backups: Litestream to S3-compatible storage, or the platform's volume snapshots, decided with the human and logged | ## Targets ### Fly.io (`fly.toml`) Verified with flyctl 0.4.115 and https://docs.fly.io/reference/configuration. 1. `fly launch --no-deploy --copy-config` with the generated `fly.toml` (the human runs it, or you do if authorized). It creates the app and keeps our config. 2. Postgres: `fly mpg create --name myapp-db --region --pg-major-version 17` → `fly mpg attach -a myapp` (sets the `DATABASE_URL` secret). Keep the pooler in its default **session** mode. Transaction mode breaks sqlx's prepared statements. 3. Secrets: `fly secrets set APP_URL=https://myapp.fly.dev SMTP_URL=… APP_ADMIN_PASSWORD=…` 4. `fly deploy` ```toml app = "myapp" primary_region = "ams" kill_signal = "SIGTERM" # Fly's default is SIGINT kill_timeout = 30 # seconds, > APP_JOBS_SHUTDOWN_GRACE [deploy] release_command = "app db migrate" # Postgres only (release machines get no volumes); replaces CMD, not ENTRYPOINT [env] SKIP_MIGRATIONS = "true" # the release command migrates; the entrypoint doesn't need to APP_TRUSTED_PROXY = "true" # Fly's proxy appends the client to X-Forwarded-For (1 hop) [processes] web = "app serve" worker = "app worker" # Postgres only [http_service] processes = ["web"] internal_port = 3000 force_https = true auto_stop_machines = "stop" # "off" when APP_INLINE_WORKER=true (SQLite): a stopped machine runs no jobs auto_start_machines = true min_machines_running = 1 [[http_service.checks]] grace_period = "10s" interval = "15s" timeout = "5s" method = "GET" path = "/up" # must return 2xx; a redirect counts as a failure ``` - **SQLite instead of Postgres:** delete `[deploy]`, the `SKIP_MIGRATIONS` env and the `worker` process. Set `APP_INLINE_WORKER = "true"` and `DATABASE_URL = "sqlite:///data/production.sqlite3?mode=rwc"` in `[env]`, set `auto_stop_machines = "off"`, and add `[mounts] source = "data"` / `destination = "/data"`. Create the volume with `fly volumes create data --size 1 --region `. Never scale beyond 1 machine. - Remote commands: `fly ssh console -C "app db status"`, `fly ssh console -C "app jobs stats"`, `fly mpg connect` (psql to MPG). - Rollback: `fly releases --image`, then `fly deploy --image `. ### Kamal 2 (`config/deploy.yml`) Verified against Kamal 2.12 docs (https://kamal-deploy.org/docs/configuration/). ```yaml service: myapp image: /myapp servers: web: hosts: [203.0.113.10] job: # Postgres only; SQLite uses APP_INLINE_WORKER hosts: [203.0.113.10] cmd: app worker stop_timeout: 30 # > APP_JOBS_SHUTDOWN_GRACE proxy: ssl: true host: myapp.example.com app_port: 3000 healthcheck: { path: /up } registry: { username: , password: [KAMAL_REGISTRY_PASSWORD] } builder: arch: amd64 # the server's arch; building on Apple Silicon needs this (or `remote:`) env: clear: APP_ENV: production APP_URL: https://myapp.example.com APP_TRUSTED_PROXY: "true" # the proxy in front appends the client IP; 2 hops when Cloudflare/Caddy sits in front too SKIP_MIGRATIONS: "true" # the pre-deploy hook migrates once (below) secret: [DATABASE_URL, SMTP_URL, APP_ADMIN_PASSWORD] volumes: ["myapp_data:/data"] # SQLite and/or local storage accessories: # only if self-hosting Postgres db: image: postgres:17 host: 203.0.113.10 port: "127.0.0.1:5432:5432" env: clear: { POSTGRES_DB: myapp_production } secret: [POSTGRES_PASSWORD] directories: ["data:/var/lib/postgresql/data"] # postgres:18+ moved PGDATA: mount /var/lib/postgresql instead ``` - Migrations run once per deploy. With **one** `web` host: let that container migrate on boot by overriding the role's env (`servers.web.env.clear.SKIP_MIGRATIONS: "false"`; roles take their own `env:`) and keep `SKIP_MIGRATIONS=true` on `job`, which boots at the same time and would otherwise race it. With several `web` hosts (deployed in parallel), none may migrate on boot: run `app db migrate` once from a `.kamal/hooks/pre-deploy` hook against the new image (Kamal's hooks docs), with every role on `SKIP_MIGRATIONS=true`. - `.kamal/secrets` pulls values from env or a password manager. Never commit literal secrets. With the accessory, `DATABASE_URL=postgres://postgres:$POSTGRES_PASSWORD@myapp-db:5432/myapp_production` (accessory containers are reachable by `-` on the kamal network). - First deploy: `kamal setup` (installs Docker, boots accessories, deploys). Accessories aren't redeployed by `kamal deploy`; use `kamal accessory boot db` / `reboot db`. - Remote commands: `kamal app exec "app db status"`, `kamal app exec -i "app db console"`. Rollback: `kamal rollback `. #### Kamal on a shared host (several apps, one box) This is the cheapest production setup, and the common one: one VPS already runs `kamal-proxy` and other apps. - **Routing:** each app sets `proxy.host: ` and `proxy.ssl: false`. kamal-proxy routes by `Host`, so **don't publish ports** from the app. - **TLS:** if Cloudflare (or a front proxy like Caddy) terminates TLS, `ssl: false` is correct, and the app must see the real client IP. Set `APP_TRUSTED_PROXY=true`, and make sure the front proxy trusts Cloudflare's `X-Forwarded-For` (Caddy: `trusted_proxies static private_ranges` when the origin is firewalled to Cloudflare only). - **Front proxy:** when a shared Caddy sits in front of kamal-proxy, a new domain needs a block in **that** Caddyfile (`http:// { reverse_proxy kamal-proxy:80 }`, plus a `www` → apex redirect), owned by whichever project runs the Caddy accessory. Write the block for the human. Editing or reloading shared infrastructure is outward-facing, so it always needs explicit confirmation, because every app on the host goes through it. - **SQLite:** a **named volume** (`"_storage:/app/storage"`) and `DATABASE_URL=sqlite:///app/storage/production.sqlite3?mode=rwc`. Docker seeds a new named volume from the image directory, so ownership by the non-root `app` user carries over. Run one container, never two, against one SQLite file. - **Builds:** `builder.arch: amd64`, and `builder.remote` pointing at a build host if the production box is busy. Kamal builds from **committed** HEAD; uncommitted changes aren't deployed. #### Behind Cloudflare: turn off script injection Cloudflare injects scripts into proxied HTML on its own: **Web Analytics / RUM** (`static.cloudflareinsights.com/beacon.min.js`, auto-enabled on new free zones), Rocket Loader, Email Address Obfuscation, and Bot Fight Mode's JS detections. The strict CSP ([security](security.md)) correctly blocks them, which logs a console error and an Issues-panel entry, and that costs points in Lighthouse Best Practices. **Never loosen the CSP to fit them.** Turn them off at the zone: - Web Analytics: `auto_install: false` alone is not enough; new zones get an enabled ruleset with a catch-all rule. Register or update the site with both off: `POST /accounts/{account}/rum/site_info {"zone_tag": "", "auto_install": false}`, then `PUT /accounts/{account}/rum/site_info/{site_tag} {"zone_tag": "", "auto_install": false, "enabled": false}`. The injection stops within about a minute. - Rocket Loader and Email Obfuscation: `PATCH /zones/{zone}/settings/rocket_loader {"value":"off"}`, and the same for `email_obfuscation`. - Verify with a real browser (or `curl -A ''`, since injection skips non-browser clients): the page must load with a clean console. ### Open-source apps: keep the deploy config private If the repository is public: - commit `config/deploy.example.yml` with `` for hosts, registry user and builder; - git-ignore the real `config/deploy.yml`, `config/deploy.*.yml` (except the example) and `.kamal/secrets*`, and add them to `.dockerignore`; - `.kamal/secrets` only references environment variables (`KEY=$KEY`), never literal values; - `docs/deploy.md` describes the topology generically (no IPs, hostnames of other services or account names); - before every commit touching deploy files, grep the staged diff for IPs, emails and `=` followed by a literal secret. ### Render (`render.yaml` Blueprint) ```yaml services: - type: web name: myapp runtime: docker healthCheckPath: /up preDeployCommand: app db migrate envVars: - { key: DATABASE_URL, fromDatabase: { name: myapp-db, property: connectionString } } - { key: SKIP_MIGRATIONS, value: "true" } - { key: APP_TRUSTED_PROXY, value: "true" } - { key: APP_URL, sync: false } - { key: APP_ADMIN_PASSWORD, sync: false } - type: worker name: myapp-worker runtime: docker dockerCommand: app worker envVars: - { key: DATABASE_URL, fromDatabase: { name: myapp-db, property: connectionString } } - { key: SKIP_MIGRATIONS, value: "true" } - { key: APP_TRUSTED_PROXY, value: "true" } databases: - name: myapp-db postgresMajorVersion: "17" ``` `sync: false` values are prompted once, when the Blueprint is created. SQLite on Render needs a `disk:` (`name`, `mountPath: /data`, `sizeGB`) and a single instance. Prefer Postgres. ### Railway Railway's config-as-code file (`railway.json`) is deprecated (legacy support ends 2026-12-01), so configure the service in the dashboard or CLI instead, and record the settings in `docs/decisions.md`: - Railway auto-detects the `Dockerfile`. Add a Postgres service and reference its `DATABASE_URL` in the app service's variables. - Pre-deploy command `app db migrate`, with `SKIP_MIGRATIONS=true` on the service. Health check path `/up`. Draining seconds 30. - A second service from the same repo with start command `app worker` (and `SKIP_MIGRATIONS=true`). ## Migrations against remote databases | Situation | Command | |-----------|---------| | Normal deploy | Automatic (entrypoint or release/pre-deploy command) | | Check status | Platform exec + `app db status` | | From a laptop | `DATABASE_URL= cargo run -- db migrate` (requires network access to the DB; confirm with the human first) | | Roll back | `app db rollback` only if the migration's `down` loses no data. Otherwise write a forward fix | ## Zero-downtime rule: expand → migrate → contract Old and new code run side by side during a deploy, so every migration must be compatible with the **previous** release: 1. **Expand:** add nullable columns or new tables. Deploy code that writes both shapes. 2. **Backfill** with an `app task`. 3. **Contract:** in a later deploy, add `NOT NULL`, drop the old columns, remove the dual-write code. Never rename or drop a column that the currently deployed code reads, in a single deploy. ## Rollback Redeploy the previous image (`fly deploy --image `, `kamal rollback `, or Render/Railway's "redeploy" or "rollback" on an earlier deploy). Because of expand/contract, the previous image works with the current schema. ## Pre-deploy checklist (agent) - [ ] Definition of Done passes (both DB backends). - [ ] **CI is green on the exact commit you deploy** (`gh run list -L 1 --json headSha,conclusion`). A local DoD doesn't replace it: CI builds from a clean checkout and catches what your machine hides (e.g. a CSS file that only exists locally). - [ ] `docker build` succeeds locally, and `/up` returns 200 from the container. - [ ] Every new env var is in `.env.example`, `Config`, and the target's config, and you've told the human exactly which secrets to set. - [ ] `APP_ENV=production` is set on every process the target runs (web and worker). An unset `APP_ENV` refuses to boot (`APP_ENV is not set`), so a missing setting shows up as a failed deploy, not as `/dev` open to the internet. - [ ] `APP_TRUSTED_PROXY=true` is set (every platform here is a proxy), with `APP_TRUSTED_PROXY_HOPS` matching the number of proxies in front. Check: two failed logins from two different networks don't share one lockout. - [ ] Exactly one process runs `app db migrate` per deploy (a release command, or one web container); every other process has `SKIP_MIGRATIONS=true`. - [ ] Migrations follow expand/contract, and `down` was tested. - [ ] SQLite prod: still a single machine, volume mounted, backups configured. - [ ] You haven't run any deploy command without the human's go-ahead. Deploys are outward-facing. ================================================================ FILE: guides/dev-tools.md (https://rustoffrails.org/docs/guides/dev-tools.md) ================================================================ # Dev Tools (`/dev`, a small Phoenix LiveDashboard) > **TL;DR** > - Every app gets `/dev` (index, routes, system, mailbox, mailer previews) and `/admin/jobs`, built by `new`, never stubbed; mounted only in development (`/admin/jobs` needs admin access in production), which listens on 127.0.0.1 and refuses foreign `Host` names (no login on `/dev`). > - Kernel pages have their own layout: every link into them carries `hx-boost="false"`, every response is `Cache-Control: no-store`, and they use no `confirm()` dialogs. > - Pending migrations: in development every HTML request shows a page with a Run migrations button; `/up` is 503 in production while any are pending. > - Agents read email via `/dev/mailbox/latest.json?to=…`; the kernel-page browser smoke is `.offrails/tools/browser-smoke/run.sh`. > - **Ask the running app, don't guess:** `GET /dev/introspect.json` (or `app introspect`) answers routes, redacted config, migrations, jobs, streams and subscribers, assets and CSS staleness. Development only; a test proves it's absent in production. > - **HTTPS in development only with a locally trusted certificate** (mkcert). Chrome drops `Set-Cookie` on a self-signed one even after "Proceed (unsafe)", so login bounces. No mkcert: `bin/dev` serves plain HTTP and says why. Smokes never pass `--ignore-certificate-errors`. > - **Hot reload:** `bin/dev` rebuilds and restarts on Rust/template edits, Tailwind watches CSS, and `static/dev-reload.js` reloads open tabs (a 1 s poll of `/dev/reload`, never a held stream). > > Read the rest when changing a kernel page, debugging pending migrations, setting up dev HTTPS, or running the browser smoke. **Use the part.** The `dev-dashboard` (and `dev-mailbox`) [parts](samples.md#the-samples) are this guide built, tested on SQLite and Postgres and security-reviewed, with more on top: `/dev/dashboard`, a LiveDashboard: live charts, requests per route, trace waterfalls, SQL fingerprints with N+1 detection and EXPLAIN, a live log tail, a routes explorer and a schema browser. When the product needs this, add the part rather than writing it from this guide: `cargo offrails add-part dev-dashboard dev-mailbox` (dependencies come first; [workflow](../workflow.md#when-the-product-grows-a-need-add-the-part)). Keep its `// SECURITY:` markers and the tests its CHECKS.md cites. This guide stays the spec: hand-write from it only when no part fits, or the human declined the part. In development the app serves a toolbox at `/dev`. It's for the human clicking around and for the agent curling around. Keep it modest: it shows what the app already knows, with no metrics pipeline and no profiler. **`generators/new.md` builds every page below in every app.** None is optional, stubbed or "added later". | Route | What | Spec | |-------|------|------| | `GET /dev` | Index: tiles with live numbers. Mailbox (unread / total), Jobs (executing / available / retryable / discarded), Mailer previews (count), Routes (count), System (pending migrations). **Try it** buttons (Send a test email, Enqueue a test job). The agent curl cheat sheet below | here | | `POST /dev/jobs/test` | Enqueue a harmless `CleanupJob`; redirect to `/admin/jobs/list` with "Enqueued test job N." | here | | `POST /dev/migrations` | The pending-migrations page's **Run migrations** button | [below](#pending-migrations) | | `/dev/mailbox` (+ Previews tab at `/dev/mailbox/previews`) | Every email the app sent, rendered; mailer previews in the same viewer; JSON for agents | [mailers](mailers.md#the-dev-mailbox-kernelmailboxrs-swooshs-local-mailbox) | | `/admin/jobs` | The jobs dashboard (also in production, behind admin access) | [jobs-dashboard](jobs-dashboard.md) | | `GET /dev/routes?q=` | The `ROUTES` table: method, path, handler, access badge. One GET search box over all three columns | [routing](routing.md) | | `GET /dev/system` | `APP_ENV`, database backend + redacted host/file, `FRONTEND_MODE`, queues, inline worker, `SMTP_URL` configured or not, crate version + git sha (`APP_REVISION`), uptime; migrations applied and **pending** (`Migrator::get_applied_migrations` / `get_pending_migrations_read_only`, sea-orm-migration 2.0: the read-only variant never creates `seaql_migrations`) | here | | `GET /dev/introspect.json` | The app's state as JSON, for agents | [below](#introspection) | | `GET /dev/reload` | `{"version": ":"}`, polled by `static/dev-reload.js` | [below](#hot-reload) | | `GET /dev/mailers` | 301 → `/dev/mailbox/previews` (previews live in the mailbox now). Build it as `(StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, "/dev/mailbox/previews")])`: axum's `Redirect::permanent` sends **308** | [mailers](mailers.md#previews) | ## Files ``` src/controllers/dev.rs router(): /dev, /dev/routes, /dev/system, /dev/sockets (with channels), /dev/introspect.json, /dev/reload, /dev/mailers (redirect), POST /dev/jobs/test, POST /dev/migrations src/kernel/introspect.rs snapshot(state, frontend_mode, live), BOOT_ID, reload_version() (the full file is below) src/kernel/css.rs class_lists, missing_classes (template classes with no rule in static/app.css), css_escape, has_selector static/dev-reload.js the reload poll (htmx mode; TS modes use Vite's own reload: islands rebuild with --watch and the poll sees the new digest) src/controllers/migrations.rs pending(), the development guard middleware, the Run button handler src/kernel/mailbox.rs router(PREVIEWS): /dev/mailbox/… src/kernel/ui.rs shared chrome (with the jobs dashboard), BOOTED_AT for uptime templates/kernel/dev/ index.html, routes.html, system.html, pending.html, sockets.html + _sockets.html (polled every 2s) templates/kernel/mailbox/ index.html, _list.html, _message.html ``` `controllers/dev.rs` holds a `pub const FRONTEND_MODE: &str` matching the app's `AGENTS.md` header. `Config` gains `revision: Option` (`APP_REVISION`). ## Rules - **Mounted only when `config.env == AppEnv::Development`**, in `routes.rs`: ```rust if config.is_development() { app = app .merge(dev::router()) .merge(mailbox::router(crate::mailers::previews::PREVIEWS)); } ``` In production the routes **don't exist** (404, not hidden). Every `/dev` route is in `ROUTES` with `Access::Dev`, and `security_test.rs` requests each one with a production `Config` and expects 404. - **Reachable from this machine only.** `/dev` has no login, so development binds `127.0.0.1` (`APP_BIND`) and `kernel::hosts` refuses any `Host` that isn't localhost, `APP_URL`'s or an `APP_ALLOWED_HOSTS` entry (DNS rebinding would otherwise read the mailbox same-origin). Opening it from a phone or a dev container takes `APP_BIND=0.0.0.0` plus `APP_ALLOWED_HOSTS`, and exposes the mailbox to that network ([security](security.md#development-is-this-machine-only)). - Server-rendered Askama + vendored htmx + `static/kernel/admin.css` + `static/kernel/admin.js` in every `FRONTEND_MODE`, sharing the jobs dashboard's header (tabs: Overview · Jobs · Cron · Dev · Mailbox · Sockets · Routes) and its flash banner. - **Every link from an app page into a kernel page carries `hx-boost="false"`**: the "Dev tools" link, the auth screens' "Check your mailbox" hint (`paths::dev_mailbox()`), any nav item into `/admin/jobs`. The htmx-mode app layout has ``, and a boosted navigation swaps only ``, so a kernel page (its own `` and stylesheet) renders **unstyled** until a hard refresh. Verified in Chrome. `kernel_web_test.rs::app_links_into_kernel_pages_are_not_boosted` scans an app page and fails on any boosted `/dev` or `/admin` link. (Inertia/react apps: use a plain ``, never ``/router navigation, for kernel pages.) - **Never cached, never stale on Back:** every `/dev`, `/dev/mailbox` and `/admin/jobs` router ends with `.layer(kernel::ui::no_store())`, so all their responses (pages, fragments, JSON, downloads, redirects) are `Cache-Control: no-store`. The kernel layout sets `historyCacheSize: 0`, and `admin.js` reloads pages restored from the back/forward cache. URLs of things that no longer exist redirect with a flash instead of a bare 404 (deleted message → inbox, deleted job → job list). - Never display secrets: the database shows backend and host (or file path) only, never credentials (`redact_db`, unit-tested), and `SMTP_URL` shows "configured" or "not set (dev mailbox)". - In development the app layout's footer (htmx) or `Layout` component (inertia/spa) shows a small "Dev tools" link to `/dev`. Render it only when the server says `env == development` (`Ctx.dev` / a shared prop). - `bin/dev` migrates first, then prints the exact URL with its scheme, `→ https://localhost:${PORT}` (or `http://` on the fallback, below) and `…/dev (mailbox, jobs, routes)`, and once more as `✓ ready: (HTTP/2|HTTP/1.1)` when the port accepts connections, so it isn't buried under build output. ## HTTPS in development Live pages need HTTP/2, and browsers only speak HTTP/2 over TLS ([realtime](realtime.md#many-tabs-stall-http11-vs-http2)). **But a certificate the browser doesn't trust breaks login:** after the user clicks "Proceed to localhost (unsafe)", Chrome silently drops `Set-Cookie`. The server answers 303 with a valid cookie, the account is created, and the browser bounces back to the login page ("email doesn't work"). Verified by a peer in a real Kanban; the same flow over `http://localhost` works. So: **dev serves HTTPS only with a locally trusted certificate.** | Situation | `bin/dev-cert` | `bin/dev` serves | |---|---|---| | mkcert installed and its CA installed (`mkcert -install`, once per machine) | writes `storage/dev-certs/{cert,key}.pem`, exit 0 | **HTTPS, HTTP/2**: `→ https://localhost:3000` | | mkcert installed, CA not installed (mkcert prints "the local CA is not installed in the system trust store") | refuses, deletes the files, says `mkcert -install`, exit 3 | plain HTTP/1.1, with the message below | | no mkcert | refuses (no self-signed fallback), says how to install mkcert, exit 3 | plain HTTP/1.1, with the message below | | `DEV_CERT_SELF_SIGNED=1` | an openssl self-signed certificate, with a warning that browsers drop cookies on it | **both, from one server:** HTTPS + h2 on `PORT` (for `curl -k` and scripted h2 checks) and plain HTTP/1.1 on `APP_DEV_HTTP_PORT` (default `PORT+1`) for the browser. `bin/dev` prints both URLs and says to open the http one | | `APP_TLS=off` | not run | plain HTTP/1.1, no message | The fallback message says: no locally trusted certificate, so plain HTTP/1.1 at `http://localhost:PORT`; login and everything work; live updates stall past about 6 tabs (HTTP/1.1 allows ~6 connections per origin); install mkcert (`brew install mkcert` · `apt install mkcert libnss3-tools`), run `mkcert -install` once, restart `bin/dev`; self-signed certificates are refused because Chrome drops login cookies on them. `bin/dev` runs `bin/dev-cert` on every start (mkcert is instant), so a stale 0.6.0 self-signed certificate in `storage/dev-certs/` is replaced or refused, never reused. The scripts are in [new](../generators/new.md#scripts--ops-files) and [realtime](realtime.md#many-tabs-stall-http11-vs-http2). ### The `bin/dev` script htmx mode (islands add `pnpm --dir web exec vite build --watch --mode development &`; `inertia` adds Vite; `spa` serves the Rust app over http behind Vite's proxy). Copy it; verified in the reference app in all three certificate situations. The PID file and process-tree stop were verified on macOS with a mock tree started from a non-interactive shell (the caller survives, every descendant and the PID file are gone, a second start refuses); `--watch=always --minify` with Tailwind 4.3.3 (a rebuild after a template edit stays minified): ```bash #!/usr/bin/env bash # Everything for development in one terminal: server (rebuilt on change) + Tailwind watcher. set -euo pipefail cd "$(dirname "$0")/.." port="${PORT:-3000}" # One PID file per server: stop exactly this bin/dev with `kill "$(cat storage/pids/dev-$PORT.pid)"`. pidfile="storage/pids/dev-$port.pid" mkdir -p storage/pids if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then echo "bin/dev is already running on port $port (PID $(cat "$pidfile")). Stop it: kill \$(cat $pidfile)" >&2 exit 1 fi if command -v lsof >/dev/null && lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null; then echo "port $port is taken by another process (lsof -nP -iTCP:$port -sTCP:LISTEN); pick another: PORT=… bin/dev" >&2 exit 1 fi echo $$ > "$pidfile" # On exit (Ctrl-C, `kill `, an error) stop our own process tree, leaves first, and nothing else. # Never `kill 0`: started from an agent's shell, bin/dev shares that shell's process group, and # `kill 0` would kill the caller too. children() { ps -A -o pid= -o ppid= | awk -v p="$1" '$2 == p { print $1 }'; } stop_tree() { local c; for c in $(children "$1"); do stop_tree "$c"; done; kill "$1" 2>/dev/null || true; } cleanup() { trap - EXIT INT TERM; rm -f "$pidfile"; for c in $(children $$); do stop_tree "$c"; done; } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM # HTTPS + HTTP/2 only with a locally trusted certificate (bin/dev-cert: mkcert + `mkcert -install`). # Without one, plain HTTP/1.1: login works, live pages stall past ~6 tabs. APP_TLS=off forces http. scheme=http open_url="" # set when the URL a person should open differs from the main one if [ "${APP_TLS:-on}" != "off" ]; then if [ "${DEV_CERT_SELF_SIGNED:-}" = 1 ] && [ -f storage/dev-certs/cert.pem ] || bin/dev-cert; then export APP_TLS_CERT=storage/dev-certs/cert.pem APP_TLS_KEY=storage/dev-certs/key.pem scheme=https if [ "${DEV_CERT_SELF_SIGNED:-}" = 1 ]; then # Untrusted certificate: browsers drop cookies on it. Same server, plain HTTP beside it. export APP_DEV_HTTP_PORT="${APP_DEV_HTTP_PORT:-$((port + 1))}" open_url="http://localhost:$APP_DEV_HTTP_PORT" fi else cat <"/dev/tcp/127.0.0.1/$port") 2>/dev/null; do sleep 1; done echo "✓ ready: ${open_url:-$scheme://localhost:$port} ($([ "$scheme" = https ] && [ -z "$open_url" ] && echo "HTTP/2" || echo "HTTP/1.1"))") & wait ``` **`APP_DEV_HTTP_PORT`** (development only; `Config` refuses it in any other environment, and it does nothing without TLS): `serve` binds a second, plain listener and serves the **same router and `AppState`** on it, so streams, presence and sessions are shared with the TLS port. The CSRF layer also trusts `http://localhost:`. Dev session cookies aren't `Secure`, so the browser keeps them there. Verified by a peer (dev-92) in a stock Chrome profile and in the reference app: login sticks and the page goes live on the http port, while the self-signed TLS port fails the login smoke's preflight. `bin/dev` in that mode: ``` → open in your browser: http://localhost:3001 (HTTP/1.1; the certificate on :3000 is self-signed and browsers drop cookies on it) → for scripts and h2 checks: https://localhost:3000 (curl -k; never a browser login) ``` **A bare `localhost:3000`** typed into the address bar is `http://` to the browser. Against a TLS listener that used to show `ERR_INVALID_HTTP_RESPONSE`. `serve`'s TLS branch now sniffs the first byte of each connection (a TLS handshake starts with `0x16`) and answers anything else with a **307** to `https://` (`kernel/tls.rs`, [deployment](deployment.md#direct-tls-optional)). 307, not 308: a cached permanent redirect would break `APP_TLS=off` on the same port later. **Never** tell a user to click through the warning, and never run a browser check with `--ignore-certificate-errors` or `acceptInsecureCerts`: that's exactly the setting under which the bug is invisible. The framework's smokes launch Chrome with no certificate overrides and fail on an untrusted certificate ([below](#browser-smoke)). ## Hot reload Templates are compiled (Askama), so "hot reload" is the boring loop, all inside `bin/dev`: 1. **Rust and templates:** `cargo watch -w src -w templates -w migration` rebuilds, migrates and restarts `serve`. 2. **CSS:** `bin/tailwindcss -i assets/app.css -o static/app.css --watch=always --minify` rewrites the CSS when a template or `assets/app.css` changes. In development the asset catalog rescans per request, so the next page gets the new digest. 3. **The browser:** the layout loads `static/dev-reload.js` when `ctx.dev`. It polls `GET /dev/reload` once a second while the tab is visible and calls `location.reload()` when `version` changes: a new `BOOT_ID` (the server restarted) or a newer file under `static/` (`version` is `BOOT_ID::`; mtimes, not digests, because every open tab asks once a second). While the server is down mid-rebuild, the fetch fails and it keeps polling. Why a poll and not the signal stream or a socket: on the plain-HTTP fallback every held connection costs one of the ~6 per origin, and a reload stream in every tab would halve the tabs a live page survives. One short request a second costs nothing. Measured in the reference app: a CSS-only edit reached an open tab in 0.6 s; a template edit in about 17 s, of which ~9 s is the incremental build inside the watcher (Tailwind rebuilding alongside), ~1.7 s startup, up to 1 s the poll, the rest the page load. **Faster rebuilds:** the skeleton `Cargo.toml` has ```toml [profile.dev] debug = "line-tables-only" # backtraces keep file:line; lldb shows no locals ``` and `.cargo/config.toml` adds `-C split-debuginfo=unpacked` on Linux (macOS's default already; Windows MSVC refuses it, which is why it left `[profile.dev]` in 0.7.2). More build-speed settings, and what was measured: [testing](testing.md#build-speed). A peer measured a template edit going from 16–23 s to 3.6–5.9 s and a Rust edit from 11–12 s to 4.4–5.3 s (a Kanban, M2). In the smaller reference app on a newer machine incremental builds were ~5 s with or without it (not reproduced there; it costs nothing). For a debugging session with locals: `CARGO_PROFILE_DEV_DEBUG=true cargo run`. A page with unsaved form input loses it on reload; that's development. ```js // static/dev-reload.js: development only (the layout loads it when ctx.dev) (() => { let seen = null; const tick = async () => { if (document.visibilityState === "visible") { try { const r = await fetch("/dev/reload", { cache: "no-store", headers: { Accept: "application/json" } }); const v = r.ok && r.headers.get("content-type")?.includes("json") ? (await r.json()).version : null; if (v && seen && v !== seen) return location.reload(); if (v) seen = v; } catch { // the server is restarting: keep polling } } setTimeout(tick, 1000); }; tick(); })(); ``` The layout line is `{% if ctx.dev %}{% endif %}`. `/dev/reload` and `/dev/introspect.json` pass the pending-migrations guard (like `/up`), so they answer while migrations are pending. TS modes: inertia/spa reload through Vite's own dev server or `vite build --watch` (islands), which changes the digest the poll sees. ## Introspection One way for an agent to ask the running app about its state, instead of grepping logs and guessing. `GET /dev/introspect.json` (development only: it's in `dev::router()`, so it **doesn't exist** in production or test) and `app introspect` (the same snapshot from the CLI; refuses unless `APP_ENV=development`; without the live parts, which only a serving process knows): | Key | What | |---|---| | `app` | name, version, `frontend_mode`, `boot_id`, `booted_at` | | `url` | `app_url`, `tls`, the browser protocol (`h2`, or `http/1.1` and its ~6-tab limit) | | `config` | the `Debug` of `Config`: an allowlist, values only for never-secret settings and `set`/`not set` for the rest ([config](config.md#srcconfigrs); never a raw env dump) | | `routes` | every `ROUTES` row: method, path, handler, access | | `migrations` | applied count, `pending` names, `current` | | `jobs` | counts by state and by queue (`admin::stats`); `null` while the table doesn't exist | | `realtime` (live) | open signal subscriptions, connections per member and transport, channel topics with subscribers and presence, frame counters, node(s). With per-stream pub/sub ([realtime](realtime.md#kernel-spec-srckernelpubsubrs)) it lists each stream and its subscriber count | | `assets` | the layout's digested asset URLs | | `css` | `built`, `stale`, and up to 50 `missing_classes` (template classes with no rule in `static/app.css`, from `kernel::css::missing_classes`) | The whole file. Copy it, then change only the lines marked `// APP:`. It's the reference implementation an upgrading app wrote from this spec (axum 0.8.9, sea-orm 2.0.4, 2026-10-13), with the app-specific lines generalized: ```rust //! Ask the running app, don't guess (.offrails/guides/dev-tools.md#introspection): the snapshot behind //! `GET /dev/introspect.json` and `app introspect`, and the version `GET /dev/reload` hands to //! `static/dev-reload.js`. Development only: the routes live in `controllers::dev::router()`, which //! production and test never mount, and `app introspect` refuses outside development. use std::fs; use std::path::Path; use std::sync::LazyLock; use std::time::SystemTime; use migration::{MigrationStatus, Migrator, MigratorTrait}; // migration/src/lib.rs: `pub use sea_orm_migration::MigrationStatus;` use serde_json::{Map, Value, json}; use crate::kernel::ui::BOOTED_AT; use crate::kernel::{css, jobs::admin}; use crate::routes::ROUTES; use crate::state::AppState; /// One id per process: a restart changes it, so open tabs reload. pub static BOOT_ID: LazyLock = LazyLock::new(|| format!("{}-{}", std::process::id(), BOOTED_AT.timestamp_micros())); /// The most missing classes listed (the count is always exact). const MAX_MISSING: usize = 50; /// `BOOT_ID::`. Mtimes, not digests: every open tab /// asks once a second. pub fn reload_version() -> String { let (mut newest, mut count) = (0u128, 0usize); walk(Path::new("static"), &mut newest, &mut count); format!("{}:{newest}:{count}", *BOOT_ID) } fn walk(dir: &Path, newest: &mut u128, count: &mut usize) { let Ok(entries) = fs::read_dir(dir) else { return }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { walk(&path, newest, count); } else if let Ok(modified) = entry.metadata().and_then(|m| m.modified()) { *count += 1; let ms = modified.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0); *newest = (*newest).max(ms); } } } /// The app's state as JSON. `live` (the HTTP handler) adds what only a serving process knows. pub async fn snapshot(state: &AppState, frontend_mode: &str, live: bool) -> anyhow::Result { let c = &state.config; let tls = c.tls.is_some(); let migrations = Migrator::get_migration_with_status(&state.db).await?; let applied = migrations.iter().filter(|m| matches!(m.status(), MigrationStatus::Applied)).count(); let pending: Vec = migrations .iter() .filter(|m| matches!(m.status(), MigrationStatus::Pending)) .map(|m| m.name().to_owned()) .collect(); // null while the jobs table doesn't exist yet (a fresh database before `db migrate`). let jobs = match admin::stats(&state.db).await { Ok(s) => json!({ "by_state": s.by_state.iter().map(|(st, n)| (state_name(st), json!(n))).collect::>(), "by_queue": s.by_queue.iter().map(|(q, counts)| { (q.clone(), Value::Object(counts.iter().map(|(st, n)| (state_name(st), json!(n))).collect())) }).collect::>(), }), Err(_) => Value::Null, }; let css = match css::missing_classes() { None => json!({ "built": false, "stale": false, "missing_count": 0, "missing_classes": [] }), Some(missing) => json!({ "built": true, "stale": !missing.is_empty(), "missing_count": missing.len(), "missing_classes": missing.iter().take(MAX_MISSING).collect::>(), }), }; let assets = state.assets.assets(); Ok(json!({ "app": { "name": crate::config::APP_NAME, "version": env!("CARGO_PKG_VERSION"), "framework": fs::read_to_string(".offrails/VERSION").ok().and_then(|v| v.lines().next().map(str::to_owned)), "frontend_mode": frontend_mode, "env": format!("{:?}", c.env).to_lowercase(), "boot_id": *BOOT_ID, "booted_at": BOOTED_AT.to_rfc3339(), }, "url": { "app_url": c.app_url, "tls": tls, "dev_http_port": c.dev_http_port, "protocol": if tls { "h2" } else { "http/1.1" }, "note": if tls { "HTTPS with ALPN h2 (a locally trusted certificate)" } else { "plain HTTP/1.1: browsers hold ~6 connections per origin, so live pages stall past ~6 tabs" }, }, "config": format!("{c:?}"), // Config's hand-written Debug redacts credentials (config.md) "routes": ROUTES.iter().map(|(method, path, handler, access)| json!({ "method": method, "path": path, "handler": handler, "access": format!("{access:?}"), })).collect::>(), "migrations": { "applied": applied, "pending": pending, "current": pending.is_empty() }, "jobs": jobs, "realtime": if live { realtime(state) } else { Value::Null }, "live": live, "assets": { "css": assets.css, "app_js": assets.app_js, "htmx": assets.htmx, "htmx_sse": assets.htmx_sse }, "css": css, })) } fn state_name(st: &impl std::fmt::Debug) -> String { format!("{st:?}").to_lowercase() } /// APP: what only the serving process knows about live connections. No realtime: `Value::Null`. fn realtime(state: &AppState) -> Value { json!({ // pubsub.streams(): (stream, receiver count) per live per-stream sender (realtime.md) "streams": state.pubsub.streams().into_iter().map(|(s, n)| json!({ "stream": s, "subscribers": n })).collect::>(), "topic_count": state.pubsub.topic_count(), // with channels: the /dev/sockets snapshot (connections per member and transport, topics, presence, // frame counters, nodes). `Snapshot` derives Serialize. // "channels": serde_json::to_value(state.hub.snapshot()).unwrap_or(Value::Null), // with SSE presence: the label per room // "presence": state.presence.labels(), }) } #[cfg(test)] mod tests { use super::*; #[test] fn reload_version_starts_with_the_boot_id() { let v = reload_version(); assert!(v.starts_with(&format!("{}:", *BOOT_ID)), "{v}"); assert_eq!(v.split(':').count(), 3, "{v}"); } } ``` The handlers (`controllers/dev.rs`, both in `router()` and in `ROUTES` with `Access::Dev`) and the CLI arm: ```rust /// `GET /dev/introspect.json`: the app's state for agents. pub async fn introspect(State(s): State) -> Result { Ok(Json(kernel::introspect::snapshot(&s, FRONTEND_MODE, true).await?).into_response()) } /// `GET /dev/reload`: polled once a second by `static/dev-reload.js`. pub async fn reload() -> Response { Json(serde_json::json!({ "version": kernel::introspect::reload_version() })).into_response() } // main.rs, `Command::Introspect` (needs the database, not the listener): anyhow::ensure!(config.is_development(), "app introspect is development only (APP_ENV is {:?})", config.env); let state = AppState::new(config, db)?; println!("{}", serde_json::to_string_pretty(&kernel::introspect::snapshot(&state, controllers::dev::FRONTEND_MODE, false).await?)?); ``` The router's `.layer(kernel::ui::no_store())` already covers both responses. Apps without the jobs kernel drop `jobs` (and its import); `api` mode has no `css` or `assets` (drop both keys and the `css` import). Add `pub fn streams(&self) -> Vec<(String, usize)>` to `PubSub` (under the map lock: each stream and its `receiver_count()`) when the app has signals. Tests (`kernel_web_test.rs`): in development it answers JSON with every key above, lists itself in `routes`, and contains none of a configured admin password or SMTP credentials; with a production **and** a test `Config`, `/dev/introspect.json` and `/dev/reload` are 404 and pages don't load `dev-reload.js`. Add both paths to `ROUTES` with `Access::Dev`. **The development CSS banner** uses the same check. Every UI app has one: if `Ctx` has no `css_warning` field yet, add it (`Option`, set by the `Ctx` extractor only when `config.is_development()`, rendered as the first plain-text line in `
`; [frontend-htmx](frontend-htmx.md#assets--cache-busting-kernelassetsrs)). An mtime check can't do this: Tailwind 4 doesn't rewrite unchanged output. ```rust // src/kernel/css.rs (next to missing_classes) /// The development CSS banner: the CSS is missing, or stale (template classes with no rule). pub fn css_warning() -> Option { if let Some(problem) = crate::kernel::assets::app_css_problem() { return Some(problem); } let missing = missing_classes()?; let first = missing.first()?; Some(format!( "{CSS_BUILT} is stale: {} template class(es) have no CSS rule (first: {first}). bin/dev rebuilds it.", missing.len() )) } // the Ctx extractor: css_warning: if s.config.is_development() { crate::kernel::css::css_warning() } else { None }, ``` ## No `confirm()` dialogs in kernel pages Kernel pages aren't boosted, so `hx-confirm` never fires there, and a native `confirm()` dialog would block the browser smoke tool. Destructive kernel actions are either cheap and dev-only (mailbox **Clear all**, deleting a job: no prompt), or go through a **server-rendered confirmation page** ("Retry 1,234 jobs?", which posts back with `confirmed=1`). If an app page needs a quick confirm, use `data-confirm="…"` on the form, handled in `static/app.js` (`submit` listener calling `confirm()`), and make browser tests accept dialogs (`page.on('dialog', d => d.accept())`). ## Pending migrations Like Rails' pending-migration error page and Phoenix's "There are pending migrations" screen: a database behind the code shows a clear page, not a "no such table" 500. `src/controllers/migrations.rs` (generated by `new`): ```rust /// Read-only: never creates seaql_migrations (a fresh database reports every migration pending). pub async fn pending(db: &DatabaseConnection) -> Result, DbErr> { Ok(Migrator::get_pending_migrations_read_only(db).await?.iter().map(|m| m.name().to_owned()).collect()) } pub async fn guard(State(s): State, OriginalUri(uri): OriginalUri, req: Request, next: Next) -> Response; pub async fn run(State(s): State, Form(f): Form) -> Result; // POST /dev/migrations ``` | Environment | Behavior | |-------------|----------| | development | `routes.rs` adds `app.layer(from_fn_with_state(state.clone(), migrations::guard))` **after** the routes and the `.fallback(...)` are added (a layer only wraps what exists when it's applied, so a fallback added later would skip the guard and unknown paths would 404 instead of showing the page). Every request except `/static/*`, `/up` and `POST /dev/migrations` gets a **503 "N pending migrations" page** (kernel layout, so it works before any app table exists) listing them, with a **Run migrations** button: a CSRF-protected form posting `return_to=` to `/dev/migrations`, which runs `Migrator::up`, flashes "Ran N migrations." and redirects back (only to same-site paths) | | production | **Never auto-migrates**: the entrypoint runs `app db migrate` before `serve` ([deployment](deployment.md)). The guard and `/dev/migrations` aren't mounted. `GET /up` returns **503 "pending migrations"** while any is pending, so a deploy that skipped migrating never goes healthy | | worker (`app worker`, inline worker) | Before starting, checks `pending()`. If any are pending it logs **once** `pending migrations: run \`app db migrate\`; worker waiting`, rechecks every 5s (stopping cleanly on shutdown), then logs `migrations applied; worker starting`. It never spams errors against missing tables | **Nothing in `serve` may read an app table before the listener is up.** The guard renders from the kernel layout and `seaql_migrations` alone; everything else that touches app tables at boot (realtime's pub/sub and cluster, the inline worker, a cache warm-up) waits for `pending()` to be empty ([realtime](realtime.md#kernel-spec-srckernelpubsubrs)). **Fresh-database smoke**, after any change to `serve` or to what starts at boot (`bin/dev` migrates first, so it never shows this): ```sh mkdir -p storage/pids && rm -f storage/fresh.sqlite3 PORT=4321 DATABASE_URL='sqlite://storage/fresh.sqlite3?mode=rwc' sh -c 'echo $$ > storage/pids/serve-4321.pid; exec cargo run -q -- serve' & sleep 5 curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4321/ # 503: the pending-migrations page, not a crash curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Sec-Fetch-Site: same-origin' -d return_to=/ http://localhost:4321/dev/migrations # 303 sleep 6; curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4321/up # 200; the log says "migrations applied; realtime starting" kill "$(cat storage/pids/serve-4321.pid)"; rm -f storage/pids/serve-4321.pid storage/fresh.sqlite3* ``` `bin/dev` runs `cargo run -- db migrate` before serving, and again on every rebuild (`cargo watch -x 'run -q -- db migrate' -x 'run -- serve'`), so the page is mostly seen after a `git pull` with the server stopped, or after a rollback. Tests (`kernel_web_test.rs`): a fully rolled-back dev DB shows the page (with the migration names, the button and `return_to`), `/up` is 503, the kernel stylesheet still loads, the button applies all and redirects back with the flash, no open redirect; production with one pending: `/up` 503, `/` unaffected, `/dev/migrations` 404. ## Browser smoke HTTP tests can't see boosted navigation, `` swaps, the back/forward cache or CSP violations. After changing kernel pages (or the app layout's links into them), drive a real browser: 1. **Preferred: the chrome-devtools MCP** (navigate, click, screenshot, `list_console_messages`), in an isolated context. 2. **When it can't attach** (Chrome's profile lock, CI, a script): the framework ships a ready smoke that launches the installed Chrome headless with a throwaway profile: ```sh BASE=http://localhost:3000 bash .offrails/tools/browser-smoke/run.sh # Linux: CHROME=/usr/bin/google-chrome BASE=… bash .offrails/tools/browser-smoke/run.sh ``` It opens `/dev` directly (styled?; an app's home need not link to it) → seeds 3 mails with the **Send a test email** button (each lands on its message, `/dev/mailbox/`, with the flash "Sent a test email to your mailbox."; the list then shows at least 3) → opens one (styled, sandboxed iframe) → **Clear all** → Back (none of the cleared messages comes back) → Forward (judged by the cleared messages' ids, not an empty list: other smokes `verify --full` runs alongside sign in, and an auth part's "New sign-in" email may arrive meanwhile) → (when `/dev` has an **Enqueue a test job** button) opens and deletes the job → Back (redirected to the list, flash shown), and fails on any CSP violation. Every step waits with `waitForNavigation`/`waitForSelector`, with no fixed sleeps, and seeds only through the dev-only buttons, so it needs no app routes. 3. **Multi-tab live updates** (any app with live pages): open N tabs in one profile on a live page, change something once, and assert every tab updates. It catches the HTTP/1.1 connection pool and an undersized per-user cap ([realtime](realtime.md#many-tabs-stall-http11-vs-http2)). The app supplies a small scenario module, `tests/e2e/live-tabs.scenario.mjs`: `export const path` (the live page), optional `login(page, base)`, `mutate(page, base)` returning a token, and `seen(page, token)` returning whether a tab shows it (the full contract is the header of `.offrails/tools/browser-smoke/smoke-live-tabs.mjs`): ```sh SCENARIO=tests/e2e/live-tabs.scenario.mjs BASE=https://localhost:3000 TABS=12 \ bash .offrails/tools/browser-smoke/run.sh live-tabs ``` It exits 1 if any tab misses the update, and says whether the page was on HTTP/1.1. Run it with 12 and 40 tabs after any realtime change. 4. **Login sticks** (every app with accounts; part of the UI check in the Definition of Done): a real browser with **no certificate overrides** fills and submits the real login (or sign-up) form, then proves a fresh page load is still logged in. It catches the dev-TLS trap above and any cookie a browser refuses (`Secure` on http, `__Host-` with a `Domain`). The scenario exports `login(page, base)` and `loggedIn(page)`: ```sh SCENARIO=tests/e2e/smoke.scenario.mjs BASE= bash .offrails/tools/browser-smoke/run.sh login ``` 5. **axe in both color schemes:** axe-core (WCAG 2.2 AA) on each path with `prefers-color-scheme` emulated light, then dark; logged-out paths from `PATHS` or the scenario's `a11yPaths`, and `a11yAuthPaths` after `login`. It finds a `bg-white` card left light under a body that turned dark. The app's Playwright suite ([test-standards §10](test-standards.md#10-accessibility-wcag-22-aa-in-every-theme)) stays the CI gate; this is the framework's own check, and it needs no Node in the app: ```sh SCENARIO=tests/e2e/smoke.scenario.mjs BASE=… bash .offrails/tools/browser-smoke/run.sh a11y ``` 6. **Every page after a boosted link** (every htmx app): each GET page in `docs/routes.md` on a full load, then reached through a boosted link from `/`; the same buttons must react, with no console errors, and no page may load its own scripts in ``. Signed out, then signed in as the demo account. No npm install (Node ≥ 18 and the installed Chrome); `verify --full` runs it when `BASE` is set ([testing](testing.md#browser-smoke-boosted-navigation)): ```sh BASE= node .offrails/tools/browser-smoke/smoke-boosted.mjs # from the app root ``` **Every smoke launches Chrome without certificate overrides** and starts with a preflight: an untrusted HTTPS certificate fails it with the explanation above, before any check runs. Verified in the reference app: against a forced self-signed certificate (`DEV_CERT_SELF_SIGNED=1`) `login` and `live-tabs` fail at the preflight; against the plain-HTTP fallback and against a trusted certificate both pass, and `live-tabs` reaches 12/12 tabs over h2. **Why it lives in `.offrails/tools/` and not in the app:** htmx-mode apps have no Node toolchain or `package.json`, and this check isn't part of the Definition of Done or CI. So `run.sh` installs `puppeteer-core` (pinned, verified 2026-10-12: 25.13.0, which needs **Node ≥ 22.12**; Node 24 LTS recommended) and `axe-core` (4.13.0) once into `~/.cache/offrails/browser-smoke` and runs a copy of the script from there. The app's dependency tree, lockfiles and stack rules are untouched, and inertia/spa apps use the same script. It's optional tooling for agents and humans; the guaranteed checks remain the Rust tests above. Verified against the bookshelf app: 11/11 checks pass. Removing `hx-boost="false"` from the dev link, or omitting `includeIndicatorStyles:false`, makes it fail. ## For agents These endpoints are your eyes on the running app. Use them in the "ran the app and exercised it" step of the [Definition of Done](../workflow.md#definition-of-done). **Start with introspection**; it answers most "why doesn't X work" questions before you read code: ```sh curl -s $URL/dev/introspect.json | jq '.migrations, .css, .realtime, .url' # $URL: what bin/dev printed (-k never needed) cargo run -q -- introspect | jq '.routes[] | select(.path | test("books"))' curl -s "$URL/dev/mailbox/latest.json?to=ada@example.com" # did the email go out? follow .links[0] cargo run -- jobs list --state discarded --json # did a job fail? cargo run -- jobs stats # is anything stuck? curl -s $URL/dev/system | grep -i pending # forgot to migrate? ``` ================================================================ FILE: guides/errors-and-logging.md (https://rustoffrails.org/docs/guides/errors-and-logging.md) ================================================================ # Errors & Logging > **TL;DR** > - One `AppError` enum in `src/error.rs` maps to responses per flavor (HTML, htmx, Inertia, JSON); handlers use `?`, never `unwrap`. > - Records the user may not know exist are `NotFound`, not `Forbidden`; unique violations go through `sql_err()`, never message matching. > - Panics render the standalone 500 page (`CatchPanicLayer::custom`); error pages are standalone templates with no DB access that load `app.js` for Back/Forward. > - Log once, in `IntoResponse`, with `tracing` (JSON in production); `/up` uses `kernel::db::ping`. > > Read the rest when adding an error variant, changing error pages, or wiring logging. **Use the part.** The `dev-dashboard` [parts](samples.md#the-samples) are this guide built, tested on SQLite and Postgres and security-reviewed, with more on top: traces as waterfalls, a live log tail and insights that rank what's failing, in development; and the `jobs-dashboard` sample groups job errors by root cause. When the product needs this, add the part rather than writing it from this guide: `cargo offrails add-part dev-dashboard` (dependencies come first; [workflow](../workflow.md#when-the-product-grows-a-need-add-the-part)). Keep its `// SECURITY:` markers and the tests its CHECKS.md cites. This guide stays the spec: hand-write from it only when no part fits, or the human declined the part. | Rails | Off Rails | |-------|-----------| | `rescue_from` / exceptions app | `AppError` + `impl IntoResponse` in `src/error.rs` | | `ActiveRecord::RecordNotFound` → 404 | `AppError::NotFound` (and `DbErr::RecordNotFound` maps to it) | | `ActiveRecord::RecordInvalid` → 422 | `AppError::Invalid(Errors)` | | `public/404.html`, `500.html` | `templates/errors/404.html`, `500.html` (htmx); `errors/Show` page (inertia); JSON (api) | | `Rails.logger` | `tracing` | | `config.log_tags = [:request_id]` | `TraceLayer` span with `request_id` | | `/up` | `GET /up`: `kernel::db::ping` (`SELECT 1`), returns 200 or 503 | | Sentry/Honeybadger gems | Only if the interview or a spec asks (log the decision) | ## `src/error.rs` ```rust #[derive(Debug, thiserror::Error)] pub enum AppError { #[error("not found")] NotFound, #[error("unauthorized")] Unauthorized, // not logged in #[error("forbidden")] Forbidden, // logged in, not allowed #[error("invalid")] Invalid(Errors), // validation failed #[error("too many requests")] TooManyRequests, // kernel/rate_limit.rs #[error("unavailable")] Unavailable, // overloaded: pool acquire timed out, live re-fetch gate full #[error(transparent)] Db(DbErr), #[error(transparent)] Template(#[from] askama::Error), #[error(transparent)] Internal(#[from] anyhow::Error), } impl From for AppError { fn from(e: DbErr) -> Self { match e { DbErr::RecordNotFound(_) => AppError::NotFound, // The pool had no free connection within acquire_timeout (5 s): overloaded, not broken. DbErr::ConnectionAcquire(ConnAcquireErr::Timeout) => AppError::Unavailable, other => AppError::Db(other), } } } // + From for AppError → Internal ``` ## Response mapping | Variant | htmx (HTML) | inertia | `/api/*` (JSON) | |---------|-------------|---------|-----------------| | `NotFound` | 404 + `errors/404.html` | 404 `errors/Show` `{status: 404}` | 404 `{"error":"not_found"}` | | `Unauthorized` | 303 → `paths::new_session()` (htmx request: `HX-Redirect` header) | 409 + `X-Inertia-Location: /session/new` (or 303 for a full visit) | 401 `{"error":"unauthorized"}` | | `Forbidden` | 403 + `errors/403.html` | 403 `errors/Show` | 403 `{"error":"forbidden"}` | | `Invalid(e)` | 422 + the `errors/_invalid.html` fragment, a list of full messages (fallback; handlers normally re-render the form themselves) | errors map → `_errors` cookie + **303 redirect back** to `Referer` (fallback: the form's GET path); see frontend-inertia | 422 `{"errors": {"field": ["msg"]}}` | | `TooManyRequests` | 429 | 429 | 429 + `Retry-After` | | `Unavailable` | 503 + `Retry-After: 1` + `errors/500.html` with "busy, retry" wording (htmx fragment requests: empty body) | 503 `errors/Show` | 503 + `Retry-After: 1` `{"error":"unavailable"}` | **Overload is 503 + `Retry-After`, never 408 and never a 30 s hang.** Three things produce it: the pool's `acquire_timeout` (5 s, `kernel::db::connect`), the live re-fetch gate ([realtime](realtime.md#keep-re-fetches-from-starving-user-requests)), and the request `TimeoutLayer` (30 s, `StatusCode::SERVICE_UNAVAILABLE`). The timeout's response comes from tower-http with no headers, so `error::retry_after` (an `axum::middleware::map_response` just outside the timeout, [routing](routing.md#middleware)) adds `Retry-After: 1` to any 503 that lacks one. 408 means the client was too slow to send its request; under a thundering herd the server was the slow one, and a 408 made browsers and proxies treat it as the client's fault. Test: with the gate full, a live re-fetch gets 503 with `Retry-After`; `From` maps `ConnectionAcquire(Timeout)` to `Unavailable` (a unit test). | `Db`, `Template`, `Internal` | 500 + `errors/500.html` | 500 `errors/Show` | 500 `{"error":"internal"}` | **The router fallback is marked.** `controllers::errors::not_found` (the router's `.fallback`) returns `([("x-route-fallback", "1")], AppError::NotFound)`, and `shape_errors` copies that header onto the page it renders. A handler's own 404 (no such record) carries no marker. `security_test.rs`'s routability check uses this to tell "not routed" from "not found" for parameterized paths too. - **How it's wired** (verified in the reference app). `IntoResponse` can't see the request, so: 1. `impl IntoResponse for AppError` returns a bare status response carrying a private `ErrorReport { status, errors: Option, detail: Option }` in its **response extensions**. It logs the 500s here (once). 2. `pub async fn shape_errors(State(state): State, req: Request, next: Next) -> Response`, mounted with `from_fn_with_state`, classifies the request into a `ResponseFlavor`: - `Api` when the path starts with `/api` **or `/streams/`, or `Accept` contains `text/event-stream`**. A logged-out stream must get a 401, not a 303 to the login page: `EventSource` can't follow a redirect to HTML (it errors and retries forever), and the stream path would become the `return_to` page after login; - `Inertia` when `X-Inertia` is present; - `Htmx` when `HX-Request` is present; - `Html` otherwise. It stores the flavor as a request extension, runs the request, and rewrites any response carrying an `ErrorReport` into the flavor's response from the table above: a JSON body, the `_errors` cookie + 303, `HX-Redirect`, or an error page. 3. It sits **outside** the CSRF layer. tower-http's `CsrfLayer::with_rejection_response(|_| AppError::Forbidden.into_response())` makes CSRF rejections flow through the same shaping. Handlers just return `AppError`. This is the one place error responses are shaped. - **Error pages never touch the DB** and never need the layout's current user. They're standalone Askama templates: `templates/errors/{404,403,500}.html`, with no `{% extends %}` and no `ctx`. The page links `/static/app.css` without a digest, and loads `/static/app.js` (`defer`), which reloads on Back/Forward: ```js // static/app.js: error pages have no htmx, so the browser's history needs a nudge window.addEventListener("pageshow", (e) => { if (e.persisted) location.reload(); }); window.addEventListener("popstate", () => { if (!window.htmx) location.reload(); }); ``` Without it, going Forward from a 404 changes the URL but not the page. `500.html` takes `detail: Option`, which is set only in development. - **Never leak internals.** 500 bodies are generic in every environment. Development may add the error chain to the HTML 500 page when `config.env == Development`, and nowhere else. - **Log every 500** at `error!` with the full chain (`{err:#}` / `?err`) inside the request span, so it carries the `request_id`. Log 4xx at `debug!`, or not at all. - Panics: `tower_http::catch_panic::CatchPanicLayer::custom(error::panic_response)` (feature `catch-panic`), so a panic becomes a logged 500 rendered with the standalone `errors/500.html` page, not a dropped connection or a bare text body. It sits outside `shape_errors`, so `shape_errors` can't style it: `panic_response(Box) -> Response` logs the panic message (`String` or `&str` payload) at error level and renders the page itself, without detail. ## Logging `main.rs` initializes `tracing-subscriber` once, as a registry with room for the layers an optional [sample](samples.md) adds (the dev dashboard's log tail and traces) beside the formatter: ```rust use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt, util::SubscriberInitExt}; fn init_tracing(config: &Config) { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::new(match config.env { AppEnv::Production => "info", _ => "info,myapp=debug,tower_http=debug", }) }); let layers: Vec + Send + Sync>> = vec![ // samples: tracing layers ]; let registry = tracing_subscriber::registry().with(sample_layers(layers)).with(filter); match config.log_format { LogFormat::Json => registry.with(tracing_subscriber::fmt::layer().json()).init(), LogFormat::Pretty => registry.with(tracing_subscriber::fmt::layer()).init(), } } /// The samples' layers, or none at all. // WHY: an empty `Vec` layer answers `Interest::never()` for every callsite, which silences the // whole subscriber (no log line at all, warnings included); `None` is a true no-op // (test: an_app_without_sample_layers_still_logs). fn sample_layers( layers: Vec + Send + Sync>>, ) -> Option + Send + Sync>>> { (!layers.is_empty()).then_some(layers) } #[cfg(test)] mod tests { use super::*; #[test] fn an_app_without_sample_layers_still_logs() { let subscriber = tracing_subscriber::registry() .with(sample_layers(Vec::new())) .with(EnvFilter::new("info")); tracing::subscriber::with_default(subscriber, || { assert!(tracing::enabled!(tracing::Level::WARN)); assert!(!tracing::enabled!(tracing::Level::DEBUG)); }); } } ``` Never pass the `Vec` itself to `.with(…)`: with no layers in it, the app logs **nothing**, not even the boot warnings (found in the samples' host app, whose log went silent; the test above fails on that version). Verified with tracing-subscriber 0.3.23, 2026-10-16. - `TraceLayer::new_for_http().make_span_with(...)` creates a span per request with `method`, `path` (the matched route, not the raw URI with ids, when available), and `request_id` from `SetRequestIdLayer` (`MakeRequestUuid`) + `PropagateRequestIdLayer` (header `x-request-id`). - **Log structured events, not prose:** `info!(post_id = post.id, user_id = user.id, "post published")`. - Workers: one span per job with `job_id`, `worker`, `queue`, `attempt` ([jobs](jobs.md#observability)). - **Never log** passwords, session tokens, reset tokens, `Authorization`/`Cookie` headers, full email bodies, or `DATABASE_URL`/`SMTP_URL` (they contain credentials). Implement `Debug` manually for `Config` (an allowlist: [config](config.md#srcconfigrs)) and for any struct holding a secret. - SeaORM SQL logging stays off (`sqlx_logging(false)`) except while debugging locally. ### Redacting stored error text Error messages quote what failed, and what failed is often a URL with its credentials (`https://deploy:hunter2@api.example.com/v1/hooks?token=…`), or an emailed link with its token. Logging them is bad enough; **storing** them (a job's `errors`, shown on `/admin/jobs`, searched, printed by `app jobs show`) keeps the secret for weeks and shows it to every admin. Text that is stored or shown after the fact goes through `kernel::redact::text` first. `src/kernel/redact.rs`, whole (from the samples' host app, trimmed to free text; compiled, clippy-clean and tested with Rust 1.99, edition 2024, 2026-10-16): ```rust //! Free text with secrets replaced by `[redacted]`, for text that is stored or shown after the //! fact (a job's failure message, an error shown on a dashboard): a URL's `user:password@`, its //! random-looking path segments and secret query values; `key=value` pairs whose key names a //! secret (`password=…`, `api_key=…`); long random-looking words (tokens). //! GUIDE: .offrails/guides/errors-and-logging.md#redacting-stored-error-text /// What a secret becomes. pub const REDACTED: &str = "[redacted]"; /// APP: names whose value is always a secret, whatever it looks like (`x_token` matches `token`). pub const SECRET_PARAMS: &[&str] = &[ "token", "code", "key", "secret", "password", "signature", "sig", "otp", ]; /// A word at least this long that looks random (base64url or hex, with a digit or a capital) is /// treated as a token. const RANDOM_MIN_LEN: usize = 20; // SECURITY: errors often quote the URL that failed, with its credentials; stored or shown error // text goes through here first (test: secrets_in_free_text_are_redacted). pub fn text(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut word = String::new(); for c in s.chars() { if c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '`') { out.push_str(&word_redacted(&word)); word.clear(); out.push(c); } else { word.push(c); } } out.push_str(&word_redacted(&word)); out } fn word_redacted(word: &str) -> String { if word.is_empty() { return String::new(); } if let Some((scheme, rest)) = word.split_once("://") { let split = rest.find(['/', '?', '#']).unwrap_or(rest.len()); let (authority, tail) = rest.split_at(split); let host = match authority.rsplit_once('@') { Some((_, host)) => format!("{REDACTED}@{host}"), None => authority.to_owned(), }; let (before_fragment, fragment) = match tail.split_once('#') { Some((b, f)) => (b, Some(f)), None => (tail, None), }; let (p, q) = match before_fragment.split_once('?') { Some((p, q)) => (p, Some(q)), None => (before_fragment, None), }; let mut out = format!("{scheme}://{host}{}", path(p)); if let Some(q) = q { out.push('?'); out.push_str(&query(q)); } if let Some(f) = fragment { out.push('#'); out.push_str(&query(f)); } return out; } if let Some((k, v)) = word.split_once(['=', ':']) && !k.is_empty() && !v.is_empty() && (is_secret_param(k.trim_start_matches(['-', '?', '&'])) || looks_random(v)) { let sep = &word[k.len()..k.len() + 1]; return format!("{k}{sep}{REDACTED}"); } if looks_random(word.trim_end_matches(['.', ':'])) { return REDACTED.to_owned(); } word.to_owned() } /// A URL path with random-looking segments (tokens in emailed links) redacted. fn path(path: &str) -> String { path.split('/') .map(|s| if looks_random(s) { REDACTED } else { s }) .collect::>() .join("/") } fn query(q: &str) -> String { q.split('&') .map(|pair| match pair.split_once('=') { Some((k, v)) if is_secret_param(k) || looks_random(v) => format!("{k}={REDACTED}"), Some(_) => pair.to_owned(), None if looks_random(pair) => REDACTED.to_owned(), None => pair.to_owned(), }) .collect::>() .join("&") } fn is_secret_param(name: &str) -> bool { let name = name.to_ascii_lowercase(); SECRET_PARAMS.iter().any(|p| name == *p || name.ends_with(&format!("_{p}"))) } /// Long, URL-safe, and not a lowercase slug: base64url tokens, hex digests, signed blobs. fn looks_random(s: &str) -> bool { s.len() >= RANDOM_MIN_LEN && s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'%')) && s.bytes().any(|b| b.is_ascii_digit() || b.is_ascii_uppercase()) } #[cfg(test)] mod tests { use super::*; const TOKEN: &str = "q8Zr2mX0kLwP4vN7tY1sB9cD3fG6hJ5aE2uI8oQ4rTw"; #[test] fn secrets_in_free_text_are_redacted() { let shown = text(&format!( "GET https://deploy:hunter2@api.example.com/v1/hooks?token=abc123&page=2 failed: 401 (key {TOKEN}); retry with password=swordfish" )); for secret in ["deploy", "hunter2", "abc123", TOKEN, "swordfish"] { assert!(!shown.contains(secret), "{secret} in {shown}"); } assert_eq!( shown, "GET https://[redacted]@api.example.com/v1/hooks?token=[redacted]&page=2 failed: 401 (key [redacted]); retry with password=[redacted]" ); assert_eq!(text(&format!("https://app.example/login_links/{TOKEN}")), "https://app.example/login_links/[redacted]"); // Ordinary text is untouched, and redacting twice changes nothing. let plain = "charging card 42: connection refused (os error 61) at hooks.partner.invalid:443"; assert_eq!(text(plain), plain); assert_eq!(text(&shown), shown); assert_eq!(text("postgres://u:p@db/app"), "postgres://[redacted]@db/app"); } } ``` It's for text, after the fact. It never replaces the rules above: secrets stay out of job args, logs and error pages to begin with. ## Spans **The rule (one, everywhere: the guides, the generators, the snippets, the parts' READMEs):** | What | Attribute | Why | |------|-----------|-----| | a controller action, a public model or service fn, a job's `perform`, a mailer fn | `#[tracing::instrument(skip_all)]` (the default level, INFO) | one or a few per request: production traces name the work | | a helper called many times per request (in a loop, per row), or a step only worth seeing while debugging | `#[tracing::instrument(level = "debug", skip_all)]` | free in production (the filter is `info`: the span is never built); the dev dashboard shows it | | anything | never without `skip_all` | arguments are secrets and personal data more often than not | So each request's traces (the `dev-dashboard` part's waterfalls, any OpenTelemetry layer) name the work it did. The template's actions follow it; so do generated code and the snippets. Add a field only when it's safe and useful, e.g. `#[tracing::instrument(skip_all, fields(id))]` for a record id. **Never put secrets or personal data in span fields** (passwords, tokens, emails, names, request bodies): `skip_all` is the default for that reason, and span fields reach logs and dashboards. ## Health check `GET /up` (in `controllers/health.rs`) calls `kernel::db::ping(&db)` (a `SELECT 1`; raw SQL stays in the kernel) with a 2s timeout and returns `200 "ok"`, or `503` when the database doesn't answer or migrations are pending ([dev-tools](dev-tools.md#pending-migrations)). It doesn't require auth, CSRF, sessions, or logging at info level. Deploy healthchecks point here. ## Gotchas agents get wrong - `.unwrap()` on a query result in a handler, which turns a 404 into a panic. Use `?` and `.ok_or(AppError::NotFound)`. - Returning `Forbidden` for records the user shouldn't know exist. Return `NotFound` when existence itself is private (other users' records). - Matching on `DbErr` message strings. Use `sql_err()` (see models.md). - Logging the same error twice (once in the handler, once in `IntoResponse`). Log in one place: `IntoResponse`. - `anyhow` in model signatures. Models return `AppError`. `anyhow` is for jobs, tasks, `main`, and the `Internal` variant. ================================================================ FILE: guides/frontend-htmx.md (https://rustoffrails.org/docs/guides/frontend-htmx.md) ================================================================ # Frontend: `htmx` mode (Hotwire → htmx) > **TL;DR** > - Askama HTML + htmx 2 (vendored) + Tailwind standalone; no Node except islands. > - The layout's `htmx-config` is required: swap 422/429/5xx (`responseHandling`), `allowEval:false` + `includeIndicatorStyles:false` (CSP), `historyCacheSize:0` + `refreshOnHistoryMiss:true`. > - Extensions (sse) load in the layout, never a page head; links into kernel pages carry `hx-boost="false"`; a 422 re-render sends `HX-Push-Url: false`. > - Fragments for `HX-Request`, full pages otherwise; small behaviors in `static/app.js` (no inline scripts); assets are digested and immutable. > > - Optional: [instant navigation for content sites](#instant-navigation-for-content-sites-optional-recipe) (View Transitions, a CSS progress bar, a versioned stale-while-revalidate service worker, prefetch on intent). > > Read the rest when building pages or partials, adding JS behavior, or using islands. Server-rendered HTML, made dynamic by htmx attributes. No Node, no bundler, no build step for JS. CSS comes from the Tailwind standalone binary. ## Setup (done by `generators/new.md`) **htmx:** vendor the latest **2.x** (this framework's syntax and config are htmx 2; htmx 4 is on npm's `next` tag and is not adopted). **Never type a version from memory.** unpkg resolves the major for you: ```sh mkdir -p static/vendor for pkg in htmx.org/dist/htmx.min.js htmx-ext-sse/dist/sse.min.js; do url=$(curl -sSLf -o /dev/null -w '%{url_effective}' "https://unpkg.com/${pkg%%/*}@2/${pkg#*/}") # → …/htmx.org@2.0.11/dist/htmx.min.js name=${pkg%%/*}; ver=$(printf '%s' "$url" | sed -E "s|.*/${name}@([^/]+)/.*|\1|") curl -sSLf "$url" -o "static/vendor/${name%.org}-$ver.min.js" # htmx-2.0.11.min.js, htmx-ext-sse-2.2.4.min.js done ``` The version lives in the filename. `kernel/assets.rs` finds each file by an exact key (`vendor/htmx-2.`, `vendor/htmx-ext-sse-`) that a test proves matches exactly one file ([assets](#assets--cache-busting-kernelassetsrs)). Verified 2026-10: htmx 2.0.11, htmx-ext-sse 2.2.4. **Tailwind (v4 standalone CLI):** the agent looks up the current v4 tag once (`curl -sSLf -o /dev/null -w '%{url_effective}' https://github.com/tailwindlabs/tailwindcss/releases/latest` → `…/tag/v4.3.3`) and writes it into **`bin/setup-tailwind`** as `TAILWIND_VERSION=v4.3.3`, so every machine, CI and the Docker build get the same binary. `bin/setup` and the Dockerfile both call `bin/setup-tailwind`, which downloads and verifies `bin/tailwindcss` (git-ignored, and in `.dockerignore`). It's `#!/bin/sh`, `set -eu`, then: ```sh os=$(uname -s | sed 's/Darwin/macos/;s/Linux/linux/'); arch=$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/') asset="tailwindcss-$os-$arch" # macos-arm64, macos-x64, linux-x64, linux-arm64 (-musl variants exist for Alpine) base="https://github.com/tailwindlabs/tailwindcss/releases/download/$TAILWIND_VERSION" bin/tailwindcss --help 2>/dev/null | grep -q "${TAILWIND_VERSION#v}" && exit 0 # already the pinned version curl -sSLf "$base/$asset" -o bin/tailwindcss && chmod +x bin/tailwindcss curl -sSLf "$base/sha256sums.txt" | grep "/$asset\$" | awk '{print $1" bin/tailwindcss"}' | shasum -a 256 -c - ``` ```css /* assets/app.css */ @import "tailwindcss"; @source "../templates"; /* Askama templates; @source paths are relative to this file */ @source "../static/app.js"; @source "../web/src/islands"; /* ISLANDS: yes only */ /* htmx's own indicator styles, because includeIndicatorStyles is off (CSP) */ .htmx-indicator { opacity: 0; } .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; transition: opacity 200ms ease-in; } ``` Build: `bin/tailwindcss -i assets/app.css -o static/app.css --minify` (`--watch` in `bin/dev`; `--watch=always` if stdin may close). Verified with v4.3.3: classes inside Askama `{% if %}` blocks are picked up. ## Hotwire → htmx | Hotwire | htmx | |---------|------| | Turbo Drive | `hx-boost="true"` on ``. Links and forms become AJAX with history | | Turbo Frame | `hx-get`/`hx-post` + `hx-target="#id"` + `hx-select="#id"` (or return a partial) | | Turbo Stream (append/replace/remove) | `hx-swap-oob="true"` elements in the response, or `hx-swap="beforeend"` | | `data-turbo-confirm` | `hx-confirm="Delete this post?"` | | `turbo_stream_from` (broadcasts) | SSE extension, see `realtime.md` | | Stimulus controllers | `static/app.js` with event delegation (below) | | Lazy-loaded frame | `hx-get="…" hx-trigger="load"` (or `revealed`) | | Turbo morphing refresh | `hx-get` the region on an SSE signal (`realtime.md`) | ## Required config: swap 422 responses By default htmx **does not swap 4xx responses**, so failed forms would silently do nothing. Put this in the layout ``. The defaults it extends (verified in htmx 2.0.11) are `204` no swap, `[23]..` swap, `[45]..` error. `allowEval: false` and `includeIndicatorStyles: false` keep htmx inside the CSP (no `eval`, no injected `