# 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 `## <version> (` 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 <email>` 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-<version>`) 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 <each installed 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/<name>/`: 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, `<link rel="alternate">` on every page), `slug` (`slugify`, accents folded, 80 chars; `unique(base, query)`: `-2`, `-3`…, per owner or global), `markdown` (pulldown-cmark + ammonia; tests for `<script>`, `on*=`, `javascript:`, iframes, styles), `pagination` (`Pager` and `shared/_pager.html`, `per` kept, the `?page` clamp), `theme-toggle` (an app-side partial and one delegated handler in `static/app.js`; no-JS POST to `/theme`; a test that the script and the server agree on the cookie), `seed-backdated` (`backdate::<E>(db, id, days_ago(30))` after the model's `create`; it needs no resource: its test makes a table of a generated table's shape, so it adds to an app whose resources are generated and owner-scoped, which refused before because it required the resource snippet, found by the 0.7.3 blog build), `public-show-by-slug` (`GET /read/notes/{slug}`: a route with a path param end to end, a nullable unique `slug` column, slugs that never move; it builds on the resource snippet's public notes, and its README says how to do the same for a generated owner-scoped resource, which it doesn't take).
  - **The CLI:** `cargo offrails snippets` (list, with what each needs and whether it's in), `snippet show <name>` (the README, every file, every wire: to read or copy by hand), `snippet add <name> [--as <model>] [--dry-run]`, `snippet remove <name>`. `add` is a recorded generation (kind `snippet` in `docs/generated.toml`): checked first (anchors, files in the way, the parts and snippets it needs), re-adding is a no-op, `remove` deletes the unchanged files and the marked lines. `--as article` renames on word boundaries (`note`/`notes`/`Note`/`Notes`: `NoteParams`, `/notes/{id}`, "Note was saved"), one-word names.
  - **CI:** `tools/ci/test-template.sh snippets` adds every snippet (resource twice: as notes and `--as article`) into a bare app and into an app with every part, runs fmt, clippy, the suite on SQLite and Postgres and offrails-check, re-adds each (no-op), removes them all in reverse and checks the app is byte-for-byte the baseline; it refuses a shipped snippet missing from its list.
  - **App migration:** *Detect:* `grep -c "samples: app js" static/app.js` is 0. *Converge:* add `// samples: app js` as the last line of `static/app.js` (its comment words must pass `public_scripts_have_no_comment_words_zap_flags`: the template's line above it reads "More behaviors (snippets, your own) go above this line, each one delegated listener."). The other snippets need only the 0.7.3 routes anchors (Generators below). *Prove it:* `cargo offrails snippet add theme-toggle --dry-run` lists its wires and refuses nothing.

- **Generators: optional, small files per feature, clean insertion and removal points** ([generators](generators/README.md#the-files-a-feature-gets)). AGENTS.md prime directive 2 was "Generate before you hand-write"; it is now **"Start app code from a snippet or a generator (both tested, both yours once copied); hand-write the rest."** Neither is mandatory. The same message in CHEATSHEET, [workflow](workflow.md) (the loop's PLAN and START steps), [new](generators/new.md) step 8, the app AGENTS.md blueprint, the `feature`, `generate`, `scaffold` and `new` skills, [RAILS_MAP](RAILS_MAP.md), [template](template/README.md), [cli](cli.md); no doc says "generate before you hand-write" any more.
  - **The split, and why this one.** A resource used to be one 400–900-line controller, a 300–500-line model, one 250-line request test, and rows inserted into the big `src/routes.rs`. Now (`g resource posts …`): `src/controllers/posts/{mod,index,show,new,edit,destroy}.rs` (one file per action *pair*: `new.rs` is new + create and `edit.rs` is edit + update, because each pair shares its form, its 422 re-render and its imports, and splitting them would duplicate both); `src/models/post/{mod,params,queries}.rs` (the entity and its relations; the untrusted input and its validation with its unit tests; the queries, which are what a reviewer audits for scoping); `src/routes/posts.rs` (its router, `paths::` helpers and `ROUTES` rows); `src/views/posts.rs` (one file: the page structs are small); `templates/posts/*`; `tests/app/posts/{mod,read_test,write_test,access_test}.rs` (read, write, and who-may: the access tests are the security review's); `tests/app/factories/post.rs`. Each file is under ~150 lines for a typical model (a 12-field model's `params.rs` is longer: validation is per field). Rejected: one file per action (seven files for one form, imports repeated seven times), and per-feature crates (one crate is one compile unit; a workspace per feature would multiply link times). Callers see one module either way (`post::find_for`, `post::PostParams`, `factories::post`).
  - **Routes per feature.** `ROUTES` is now `APP_ROUTES` (the kernel's and the parts' rows, as before) followed by each feature's table, listed in `TABLES` and concatenated at compile time by two `const fn`s, so it stays one `&[Route]` that every test, `app routes`, docs/routes.md and the parts' own tests iterate unchanged. Generators (and snippets) wire a feature with one line at each of `samples: route mods`, `routes`, `paths` and `route tables`. `g controller`, `g page` and `g api` (the API kernel's routes in a shared `src/routes/api.rs`, each resource in `src/routes/api_v1_<p>.rs`) do the same. `g page` is one set of files per page (`src/controllers/pages/<page>.rs`, `src/views/pages/<page>.rs`, `tests/app/pages/<page>_test.rs`) instead of functions appended to shared files.
  - **Every wiring line names its owner.** A one-line statement or declaration ends with `// posts` (`{# posts #}` in HTML); a list element, field, match arm or block gets a `// posts` line above it (rustfmt aligns a comment that follows a trailing one in a list, and drags it onto the anchor). Declarations (`pub mod x;`, `pub use …;`) are inserted in sorted position, because rustfmt reordering a marked last line moves its comment onto another line (found by the CI: `pub mod recurring; // notify_post_published`). `destroy`, and a human, find a feature's lines by name; adding touches a shared file only through these one-line inserts (two for a factory, a few in the tenancy suite and the parent model's `has_many`). Golden files and the byte-identical-after-destroy CI keep both guarantees.
  - **Path params for `g controller` and `g page`:** `g controller blog index show:/blog/{slug}` (or `--path /blog/{slug}` for one action), `g page profile --path /people/{handle}`. The handler takes `Path(slug): Path<String>` (`{id}` and `{…_id}` are `i64`; several: a tuple), the page struct gets the field (escaped by Askama), the helper is `paths::blog_show(slug: &str)`, the test requests `paths::blog_show("example")` and finds it on the page. A page with a parameter gets no sitemap entry; a signed-in page with one is listed in the tenancy suite's `NOT_TENANT_IDS`. `--nav` refuses a first action with parameters.
  - **The generated HTML is in two files**, `tools/offrails-gen/src/generate/files/html/resource.html` and `pages.html` (one section per piece: the index, its header and cells, show and its rows, new, edit, the form, each control, the nav link, the controller and page templates), with `__NAME__` tokens, instead of format strings inside the generators. Swapping the markup is editing those two files; the golden files show the result. The split itself was byte-identical (the goldens didn't move); the kit came next (Generators and snippets on the kit, below).
  - **Other generator fixes:** a generated boolean checkbox has its `dark:` partner (`accent-indigo-600 dark:accent-indigo-400`); every generated index has a test that a huge `?page=` answers 200; the next steps say to commit right after the run.
  - **App migration:**
    - *Feature routes files.* *Detect:* `grep -c "samples: route mods" src/routes.rs` is 0. *Converge:* from `.offrails/template/app/src/routes.rs`, copy the `// samples: route mods` block (after the `use` lines), `pub type Route`, `pub const ROUTES` (the `concat` line), `TABLES` with `// samples: route tables`, and the two `const fn`s at the end; rename the app's existing table `pub const ROUTES: &[(&str, &str, &str, Access)] = &[` to `const APP_ROUTES: &[Route] = &[` (its rows and its `// samples: routes table` stay). *Prove it:* `cargo run -q -- routes` lists the same rows as before, and `cargo nextest run security_test smoke_test` passes.
    - *Route sweeps.* *Detect:* `grep -c "src/routes" tests/app/security_test.rs` is 0. *Converge:* take `concrete()` (any other `{param}` becomes `example`, or `1` for an id) and `every_mounted_route_is_in_the_routes_table` (it also reads every `src/routes/*.rs`) from the template's `tests/app/security_test.rs`, and make `smoke_test.rs`'s `every_listed_route_is_routable_in_development` call `crate::security_test::concrete(path)`. *Prove it:* the two tests pass; after a `g controller x show:/x/{slug}`, both still pass.
    - *Generations recorded before 0.7.3.* *Detect:* entries in `docs/generated.toml` of kind `resource`, `model`, `controller`, `page` or `api` without `layout = "2"`. *Converge:* nothing, if you keep them (the code is yours; it compiles as it is). To remove one, `destroy` refuses with its file list: delete those files and its lines in the shared files by hand, then `cargo offrails destroy <kind> <name> --forget` drops the record. Migrations, jobs, mailers, channels and tests from before destroy as they did. *Prove it:* `cargo offrails g resource …` of a new feature writes the split layout, and `destroy` of it leaves `git status` clean.
    - *Agents.* Log in `docs/decisions.md`: `YYYY-MM-DD: snippets and generators are optional starting points (framework 0.7.3); features are small files per feature with owner-marked wiring`.
  - **Changed code:** `tools/offrails-gen` (`generate/feature.rs` routes files and path params, `generate/snippet.rs`, `plan.rs` markers and sorted inserts, `model.rs`/`resource.rs`/`controller.rs`/`page.rs`/`api.rs` the split, `routes_md.rs` reading every routes file, golden files for path params and a test that the resource snippet is the generator's output).

- **The blog build's friction, fixed in the framework** (the list from the 0.7.2 rebuild; the samples-repo items, invite-only auth, the parts' hard-coded `/account`, the dev mailbox's "CODE 2026" and the AI briefing card, came with the parts sync below):
  - **Template:** `Page` clamps `?page=` to 1..=`Page::MAX_PAGE` (100 000): `?page=18446744073709551615` overflowed `(page − 1) × per`, a panic and a 500 on every generated index (unit test `a_huge_page_is_clamped_not_overflowed`; generated `a_page_past_the_end_is_empty`). The production pending-migration test requests `/robots.txt`, not `/` (an app's home that reads a generated table broke it). `tasks_run_by_name_and_refuse_unknown_ones` runs `seed` under a development config (seeds may refuse outside development). `static/app.js` ends with the `samples: app js` anchor.
  - **`new`:** the generated AGENTS.md no longer shows the raw `{{TS modes only…}}` placeholder; `--pitch` also replaces the home page's sentence, the sentence forms (description, home) are capitalized with their period, and the tagline is one value, brand.toml's `tagline` (the pitch capitalized, without its period), which `config::APP_TAGLINE` now is (`pub const APP_TAGLINE: &str = crate::brand::TAGLINE;`), so the home page title, the social card and a nav that shows the tagline agree (`--pitch` wrote the pitch as typed, often lowercase, into `APP_TAGLINE` beside brand.toml's capitalized sentence, which nothing but the social card read; found by the 0.7.3 blog build; unit tests check the template still has every token and `APP_TAGLINE` is the brand's); a pitch with quotes, braces or angle brackets is refused; on macOS it writes `target/.metadata_never_index` so Spotlight doesn't index build output.
  - **Browser smokes:** `smoke-dev-tools.mjs` accepts the dev mailbox's test button landing on the message it sent (dev-mailbox 0.7.2+) as well as on the list; `smoke-dev-tools.mjs` judges Clear all, Back and Forward by the cleared messages' ids, not by an empty list (in `verify --full` with an auth part, the other smokes' sign-ins send "New sign-in" emails at the same moment, and Back/Forward failed on them; found verifying a 0.7.3 app with every auth part); `run.sh` copies each run's scripts into its own directory (parallel smokes overwrote a shared `lib.mjs` while another read it) and exits 77 (skip) when Node is older than 22.12; `verify` reports the kernel-page smoke and the a11y smoke as skipped on 77, never failed or passed. The a11y smoke finds axe-core the way Node finds `puppeteer-core`, from the nearest `node_modules` up (the cache): `lib.mjs` read `./node_modules/axe-core` next to itself, which the per-run copy doesn't have, so `verify --full`'s "browser a11y" failed with ENOENT on every app (found by the 0.7.3 blog build); `test-template.sh prove` now runs `run.sh a11y` with a scenario, as `verify` does. The puppeteer smokes (`run.sh dev-tools | live-tabs | login | a11y`) find Chrome the same way as the CDP smokes (`cdp.mjs`: `CHROME_PATH`/`CHROME`, else the usual places on macOS, Linux and Windows) instead of defaulting to the macOS path, and skip (exit 77) when there is none.
  - **Coverage with Homebrew's rust:** `verify --full` points cargo-llvm-cov at Homebrew's `llvm` (`LLVM_COV`, `LLVM_PROFDATA`) when the active `rustc`'s sysroot has no `llvm-tools` (`rustc --print sysroot`, then `lib/rustlib/<host>/bin/llvm-cov`) and they aren't set ([testing](guides/testing.md#coverage-and-mutation-testing)). Deciding by whether rustup exists, as first written, missed the common case found by the 0.7.3 blog build: rustup installed but Homebrew's `rustc` first on PATH, where llvm-cov failed until both were set by hand (unit test `llvm_tools_are_looked_for_in_the_active_sysroot`).
  - **Flaky tests show why:** `verify` runs nextest at `--status-level retry`, so a test that fails and then passes on retry keeps its failed attempt's output in `target/verify/tests-*.log` (before, only the `FLAKY` line), and the template CI prints that output when a flaky test is not a known one.
  - **Docs:** `SMOKE_EMAIL`/`SMOKE_PASSWORD` (and `SMOKE_PATHS`) in the Definition of Done table; the `assert_text` apostrophe gotcha (it escapes like Askama, so literal template text needs `assert_contains`; [testing](guides/testing.md#gotchas-agents-get-wrong), CHEATSHEET 26); one span rule everywhere (`skip_all` on actions and public model fns, `level = "debug", skip_all` on per-row helpers; [errors-and-logging](guides/errors-and-logging.md#spans), CHEATSHEET 27); commit after each generator or snippet run ([workflow](workflow.md#commits), [generators](generators/README.md#rules)); how a sub-agent hands a running dev server back (detached with `nohup`, its PID file and log, [cli](cli.md#running-the-app-from-an-agent)); [upgrade](generators/upgrade.md) step 6's 0.7.1 caveat for `update-part`; [authentication](generators/authentication.md) rewritten around the auth parts (what they do, what's left: invite-only, roles, teams), and [new](generators/new.md) (steps 2–3 and Tests) and [auth](guides/auth.md) point at the parts too: they still said `authentication.md` builds the guard and tenancy suites (the first owner-scoped `g resource` writes them) and "generates" accounts (found by the 0.7.3 blog build); [routing](guides/routing.md#a-features-routes-file), [structure](structure.md), [models](guides/models.md), [testing](guides/testing.md) and [controller](generators/controller.md) for the new layout.
  - **A framework checkout is vendored from HEAD, and says so.** [new](generators/new.md#1-generate) §1 told agents to export "the tag matching `VERSION`", which on a release branch before tagging is the previous release (the 0.7.3 blog build vendored `git archive 044d7eb` by hand and logged why). `new`, `vendor` and `update-part` now handle a `--framework`/`OFFRAILS_FW` (or the generator's own root) that is a git checkout themselves: they use HEAD's committed files (`git archive HEAD | tar -x`, as `tools/release/release.sh` builds the tarball), never the working tree (uncommitted changes are named and left out), and `new`/`vendor` write the commit as `.offrails/VERSION`'s third line and in the `new` log line: `from git v0.7.3 (<sha>)` at the tag, `from git <sha> (unreleased: HEAD is not the v0.7.3 tag; VERSION names the last release)` past it. `VERSION`'s first line (and the `FRAMEWORK` header) stay the version, so `offrails-check` and `upgrade --check` read it as before; release installs (the plugin, `~/.offrails/current`, a tag export: no `.git`) are copied as before, with nothing added. `--worktree` uses the working tree as it is, recorded as such: `test-template.sh` passes it, since CI tests uncommitted changes (unit test `a_framework_checkout_is_vendored_from_head_with_its_commit`; [upgrade](generators/upgrade.md)).
  - **CI:** `test-template.sh` takes `OFFRAILS_CI_TAG` (a suffix for the apps' crate names, so two checkouts' runs on one Postgres server never share test databases) and `OFFRAILS_CI_PORT` (the smokes' first port).
  - **App migration:**
    - *The `?page` clamp.* *Detect:* `grep -c MAX_PAGE src/models/mod.rs` is 0. *Converge:* in `Page`, add `pub const MAX_PAGE: u64 = 100_000;` and make `number()` `self.page.unwrap_or(1).clamp(1, Self::MAX_PAGE)`; copy the template's `a_huge_page_is_clamped_not_overflowed` test. *Prove it:* `curl -s -o /dev/null -w '%{http_code}' "$URL/<a list>?page=18446744073709551615"` prints 200.
    - *Tests that assume the home page and the seed.* *Detect:* `grep -n 'app.get("/").await.assert_ok()' tests/app/kernel_web_test.rs` inside `production_with_a_pending_migration_is_unhealthy_but_serves`, and `TestApp::new()` in `tasks_run_by_name_and_refuse_unknown_ones`. *Converge:* `/robots.txt` there; `TestApp::with_config(Config { env: AppEnv::Development, ..Config::test() })` here. *Prove it:* both pass.
    - *AGENTS.md.* *Detect:* `grep -c "TS modes only" AGENTS.md`. *Converge:* delete `{{TS modes only: ", or :5173 via Vite"; htmx: nothing}}` (htmx), or replace it with `, or :5173 via Vite` (inertia, spa). *Prove it:* `grep -c "{{" AGENTS.md` is 0.
    - The smokes, `verify` and the docs arrive with re-vendoring; nothing to converge.

- **Polish and proof: one file rebrands the app, five layouts, a component kit, and `cargo offrails prove`** ([views](guides/views.md#brand), [testing](guides/testing.md#proof-cargo-offrails-prove)). The human: "make it quick and easy to polish an app ... layout, branding ... components ... FOR AI. how to make agents move fast and precisely, and test, take screenshots, build a guide proving what they build works."
  - **`brand.toml` + `cargo offrails brand`** (std-only, in `offrails-gen`; `new` runs it). Name, tagline, `primary`/`accent`/`neutral` colors, optional `on_primary`, font stacks, radius, and the logo (a 1–2 character mark or a square SVG). It renders `assets/brand.css` (Tailwind `@theme`: `primary-*` and `accent-*` scales built in OKLCH with 600 exactly the brand color, `gray-*` = the neutral, `indigo-*` = primary so parts and older templates follow, `on-primary`, `primary-hover`, fonts, radius; semantic `--brand-*` tokens for light and dark), `src/brand.rs` (`NAME` is now `config::APP_NAME`; `THEME_COLOR` feeds the theme-color meta; `brand::email::*` colors, and the mailer part's header is pointed at them), `static/favicon.svg`, and the social card `assets/brand/og.svg`. Before writing anything it checks 16 text/surface pairs the components use (body, muted, links, badges, button text and its hover; light and dark) against WCAG AA 4.5:1 and refuses with the pair, its ratio and a color that passes; the scales are built so that the shades holding text pass for any base color (a yellow brand gets near-black button text). PNGs (`favicon.ico`, `apple-touch-icon.png`, `icon-512.png`, `og.png`) are rasterized by the installed Chrome/Chromium/Edge (headless) or `rsvg-convert`; with neither the SVGs are current and it says the PNGs aren't (no image crate: a text mark needs a font rasterizer). `--check` fails when a generated file is stale (`verify --full` runs it). `tools/brand/render.sh` is superseded (kept for apps without a `brand.toml`; `assets/brand/og.html` left the template). 24 unit tests (color math against WCAG reference values, scales, contrast refusals and fixes, the TOML subset, outputs, the ICO container, rustfmt-stable `brand.rs`).
  - **Layouts** (`templates/layouts/`): `application.html` is now the shell (head, skip link, icon sprite, overlay slots, dev footer) whose `body` block is the top-nav layout; `sidebar.html` (folds behind Menu on a phone), `marketing.html` (hero, nav, CTA, footer), `auth.html` (centered card), `docs.html` (table of contents beside the article). One `<head>` for all of them (boosted links), a skip link to `<main id="main">`, landmarks, dark mode, no sideways scroll at 360–390 px. Pages pick one with `{% extends %}`.
  - **Component kit** (`templates/components/ui.html`, one import): button (variants, sizes, icon, disabled), link_button, icon_button, confirm_button, icon; field (text/email/password/…), textarea, select, checkbox, radios (labels, hints, errors with `aria-invalid`/`aria-describedby`, values kept on a 422, passwords never echoed), form_actions; alert, flash, toast/toast_oob; card, badge, stat, table (caption, sortable header links with `aria-sort`, empty state), pagination (`helpers::Pager`), empty_state, avatar; breadcrumbs, page_header, nav_tabs, tabs/tab_panel, dropdown/menu_item; modal (native `<dialog>`: inert page, focus kept inside, Esc, focus returned), modal_button, modal_link (htmx-loaded, opens itself, leaves when closed), modal_close, and the layout-wide confirm dialog for every `data-confirm` and `hx-confirm`. Styles are component classes in `assets/app.css` (brand tokens, light + dark); behaviors are `static/components.js` (delegated; the `data-confirm` handler moved there from `app.js`, which now keeps `aria-expanded` in step for `data-toggle`). Every component in every state at `/dev/components` (`?theme=light|dark`), each layout at `/dev/layouts/<name>` (development only, `Access::Dev`); guides/views.md has the signatures as one table.
  - **Tests:** `theme_test` renders the gallery and the layout demos and checks every class list for its `dark:` partner and its rule in the built CSS; `components_test` (skip link, landmarks and one `<head>` in every layout, the 422 keeps values, sort and pagination, development only); `tools/browser-smoke/smoke-components.mjs` drives the modal (Tab, Esc, focus return), the htmx modal, the confirm dialog (Cancel and Confirm) and the 422 on a full load and after a boosted link; the boosted smoke covers the gallery's dropdowns, tabs, toggles and dismiss buttons.
  - **`offrails-check [classes]`** (a warning, never an error): a `<button>`, `<input>`, `<select>`, `<textarea>` or button-like `<a>` with more than eight utilities (`CLASS_LIST_MAX`) in an app template outside `templates/components`, `kernel`, `mailers`; `{# offrails-check: classes ok #}` on the line silences it. A line an installed part ships as it is (in its copy of the same file in `.offrails/template/parts/<part>/files/`, crate names aside: the base `update-part` merges from, or in a part's wiring in `sample.toml`, since parts wire into each other's templates, e.g. `shared/_auth_nav.html`) is skipped and counted (`skipped N line(s) the installed parts ship`): the auth parts' own `password_sessions/_confirm.html`, `_form.html`, `passwords/_new_password.html`, `registrations/verify.html` and `security/not_me.html` warned in every app that installed them (found by the 0.7.3 blog build); moving them onto the kit is a samples change. A line the app wrote or edited is checked. In `tools/ci/test-offrails-check.sh` (a part's unmodified line, an edited one, and a wired one).
  - **`cargo offrails prove`** (`offrails-gen` launches `tools/browser-smoke/prove.mjs`: Node ≥ 18 and the installed Chrome over the DevTools Protocol, the driver and browser discovery every smoke shares (`tools/browser-smoke/cdp.mjs`, below), no npm install; exit 77 without them). It starts nothing: with `BASE` set it visits every GET page of `docs/routes.md` (plus `SMOKE_PATHS`, minus `SMOKE_SKIP`) as the visitor who can see it (signed in as the demo account when a page sends you to sign in), takes full-page screenshots at desktop 1280 and mobile 390 in light and dark, and fails on console errors, uncaught exceptions, CSP violations, axe-core violations (WCAG 2.2 AA, light and dark; the a11y smoke's pinned axe from the smokes' cache, installed there once if missing), broken same-site links and `#anchors`, and sideways scroll at 390 px (naming the elements that stick out; on kernel pages, which apps don't own, a warning unless `--strict`: its first run on the all-parts app found the dev dashboard's requests and lab pages and `/admin/jobs`, `/admin/jobs/cron` scrolling sideways on a phone, a samples fix to make upstream). Each screenshot is diffed against the previous run's (PNG decode/encode with `node:zlib`, a per-pixel threshold): reported with a diff image, failing only with `--strict`. It writes `docs/proof/PROOF.md` (result, checks, pages with their four screenshots and changes, and every acceptance criterion → the tests citing it with their result from the last `verify` → the pages they request with screenshots) and `proof.json`; PNGs are git-ignored unless `--commit-screenshots`. `verify --full` runs `brand --check`, the component smoke and `prove` when `BASE` is set (a separate `polish_steps` block); workflow.md's Definition of Done has the proof row and the final message links PROOF.md.
  - **Measured** (M2, 8 cores, other agents building on the machine): the bare app, 9 pages, 36 screenshots, axe on 18 loads: 13 s at load ~15, 20 s (first run) and 24 s (second run, with the diffs) at load ~50; the app with every part, 63 pages (57 signed out, 6 signed in as the demo account), 252 screenshots: 165 s and 216 s at load 50–70. Its second run reported 82 changed screenshots out of 252 (the dev dashboard's live charts, timestamps, uptime): reported, not failed, as designed.
  - **CI:** `tools/ci/test-template.sh prove` (its apps' crate names take `OFFRAILS_CI_TAG` and its two servers the ports after the smokes', `OFFRAILS_CI_PORT` + 2 and + 3, like the other targets) generates the bare app and the app with every part, adds a fixture feature whose two criteria cite request tests, runs `verify` (SQLite + Postgres) and offrails-check, serves each in development, runs the component smoke and `prove` twice, and checks that PROOF.md says PROVEN, has four screenshots per page, traces `AC-home-1` to its passing test and to `/`, that the second run compared screenshots, and that the PNGs are git-ignored. Its fixture also has a helper whose doc comment cites `AC-home-2`, which PROOF.md must not list: `prove` counted the first `fn` after any AC id as a citing test, so handlers and helpers whose comments name a criterion (`session_delete` in `src/routes.rs`, queries, module docs, string literals in the dev dashboard's tests) showed up as its tests (found by the 0.7.3 blog build). In Rust it now counts only test functions: a test attribute (`#[test]`, `#[tokio::test]`, `#[sqlx::test]`, `#[rstest]`, …) among the citation's attributes and comments, only those between it and the `fn`, and in `src/` a `#[cfg(test)]` module ([testing](guides/testing.md#proof-cargo-offrails-prove)). Its apps use a sticky-footer layout (`<body>` a flex column, `<main>` `w-full flex-1`, as apps often make it), and `/dev/components` and every `/dev/layouts/*` page must be ok in PROOF.md (no sideways scroll at 390 px): `/dev/components`' own `<main>` had `mx-auto` without `w-full`, so in such a layout it shrank to its content and the table's min-content made it 396 px wide at 390 px, which `prove` counts as a failure (a boosted page; found by the 0.7.3 blog build). It has `w-full` now.

  **App migration** (an app on 0.7.2; optional, but `verify --full` runs the new steps once re-vendored):
  - *Brand.* *Detect:* no `brand.toml` at the app root. *Converge:* re-vendor, then `cargo offrails brand --init` (writes `brand.toml` with the name from `src/config.rs` and the description from `src/views/mod.rs`, without its period, as the tagline); put the app's tagline in brand.toml's `tagline` (the one in `APP_TAGLINE`, capitalized); set the colors you use (the template default primary is `#4f46e5`); copy `@import "./brand.css";` (after `@import "tailwindcss";`) and `@source "../static/components.js";` into `assets/app.css`; add `pub mod brand;` to `src/lib.rs`; set `pub const APP_NAME: &str = crate::brand::NAME;` and `pub const APP_TAGLINE: &str = crate::brand::TAGLINE;` in `src/config.rs` (a smoke test that asserts the home page title then sees the capitalized tagline); replace the layout's `<meta name="theme-color" content="#…">` with `{{ crate::brand::THEME_COLOR }}`; run `cargo offrails brand`. *Prove:* `cargo offrails brand --check` prints "is current" and `cargo nextest run theme_test` passes.
  - *Layouts and components.* *Detect:* no `templates/components/ui.html`. *Converge:* copy from `.offrails/template/app/`: `templates/components/`, `templates/layouts/{sidebar,marketing,auth,docs}.html`, `templates/shared/{_brand,_notices,_sidebar}.html`, `static/components.js`, the component layer of `assets/app.css` (keep your own classes), `src/views/helpers.rs`'s component helpers (`same`, `tone_icon`, `initials`, `PageNumber`, `Pager`), the `components_js` asset (`src/kernel/assets.rs`: the field and the `"components.js"` key in the same position), and the gallery (`src/controllers/gallery.rs`, `src/views/gallery.rs`, `templates/dev/`, its routes in `controllers/dev.rs`, `ROUTES` and `paths`); then make `templates/layouts/application.html` the shell: wrap its nav-and-main in `{% block body %}…{% endblock %}`, give `<main>` `id="main"`, and add the skip link, `{% include "components/_icons.html" %}`, `{% include "components/_overlays.html" %}` and the `components.js` script as the template's does (keep the `samples:` anchors). Delete the `data-confirm` listener from `static/app.js` (components.js has it). Copy `tests/app/components_test.rs` and the gallery test in `tests/app/theme_test.rs`. *Prove:* `cargo offrails verify`, then with `bin/dev` running `BASE=… node .offrails/tools/browser-smoke/smoke-components.mjs`.
  - *Proof.* *Detect:* `cargo offrails prove --help` is unknown. *Converge:* re-vendor. *Prove:* `BASE=<bin/dev's URL> cargo offrails prove` prints `proven` and writes `docs/proof/PROOF.md`; link it in your final message from now on. Log `YYYY-MM-DD: upgraded to framework 0.7.3 (brand.toml, layouts, component kit, prove)` in `docs/decisions.md`.

- **Generators and snippets build their pages from the component kit, in a layout you pick** ([generators](generators/README.md#what-the-generators-decide-so-you-dont), [views](guides/views.md#components)). The two branches met here: the generators' markup had just moved into `files/html/{resource,pages}.html`, and the kit had just landed. Every generated page now calls the kit, so it follows `brand.toml`, both themes and the kit's accessibility, and `offrails-check [classes]` finds nothing in it (0 warnings on an app with a resource of every field type, a references chain, pages and controllers; was 0 before too, but only because the old markup was hand-checked).
  - **Index:** `ui::page_header` with a `ui::link_button` ("New post", `plus` icon); `ui::table` (caption, the list's columns; the actions column is gone: Edit is on the show page) or, empty, `ui::empty_state` with its call to action; `ui::pagination` with `pages()` = this page + 1 when the query found more (the generated `all` returns `more`, no COUNT) and `page_base()`. Before → after (`templates/posts/index.html`, abridged):
    ```html
    <div class="flex flex-wrap items-end justify-between gap-4">
      <h1 class="text-3xl font-semibold tracking-tight">Posts</h1>
      <a href="{{ paths::new_post() }}" class="btn">New post</a>
    </div>
    {% if posts.is_empty() %}<p class="mt-10 rounded-xl border border-dashed … p-10 text-center …">No posts yet. …</p>{% else %}
    <div class="mt-8 overflow-x-auto rounded-xl border …"><table class="min-w-full divide-y …">…</table></div>{% endif %}
    {% if newer().is_some() || older().is_some() %}<nav …>← Newer … Older →</nav>{% endif %}
    ```
    ```html
    {% import "components/ui.html" as ui %}
    {% call ui::page_header("Posts") %}{% call ui::link_button("New post", paths::new_post(), icon = "plus") %}{% endcall %}{% endcall %}
    {% if posts.is_empty() %}{% call ui::empty_state("No posts yet", "Create the first one.", "New post", paths::new_post()) %}{% endcall %}
    {% else %}{% call ui::table("Posts, newest first", [("", "Name"), ("", "Title")]) %}<tr id="post_{{ post.id }}"><td>…</td></tr>{% endcall %}{% endif %}
    {% call ui::pagination(page, pages(), page_base()) %}{% endcall %}
    ```
  - **Show:** `ui::page_header` with Edit (`link_button`) and Delete (`ui::confirm_button`, the layout's confirm dialog); the fields in a `ui::card`. **New/edit:** `ui::page_header` and the form in a `ui::card`. **Form:** `ui::field` (text, number with `step`/`inputmode`, decimal, money with a `0.00` placeholder, `datetime-local`, date, a monospace uuid), `ui::textarea` (text; json monospace), `ui::checkbox` (boolean), `ui::select` (an optional boolean: Not set/Yes/No; a reference: its choices), each with its label, the required mark, `aria-invalid`/`aria-describedby` and its errors; `ui::form_actions` (Save, Cancel). **`g controller` / `g page`:** `ui::page_header` and the placeholder paragraph. The flash is the layout's: `shared/_flash.html` is now the kit's `ui::flash` (dismissible).
  - **`--layout application|sidebar|marketing|auth|docs`** on `g resource`, `g controller` and `g page` (default `application`, the top nav; recorded as `page_layout` in `docs/generated.toml`, so a re-run and `destroy` see the same pages). A layout the app doesn't have is refused.
  - **The kit, for generated forms:** `field` takes `maxlength`, `inputmode`, `step`, `mono` (monospace, no autocorrect) and `key` (the errors' key when it isn't the input's name: a `book_id` select reports on `book`); `textarea` takes `mono` and `key`; `select` takes `key`, and its option values may be ids (`helpers::same` compares any `Display`, so `Vec<(i64, String)>` choices work as they are); `page_header`'s title wraps (`break-words`). `helpers::error_key` is new, with unit tests.
  - **Snippets:** `resource` is re-exported from the generator (the unit test that it equals `g resource notes title:string body:text --public` still passes); `pagination` is `views::pager::PageLinks { number, pages, base }` (renamed from `Pager`, which is the kit's helper) and `shared/_pager.html` is one `ui::pagination` call (`?per=` kept in `base`, tests for the links, `per`, the clamp and the partial); `public-show-by-slug`'s page uses `ui::page_header`; `theme-toggle`'s button is the kit's `btn-ghost btn-sm` (the kit has no theme switch of its own; it had 11 utilities, an `offrails-check [classes]` warning).
  - **Proof:** the goldens (`resource_items`, `page_about`, `page_profile_param`, `controller_reports`, `controller_blog_params`) show every page; an app with a resource of every field type, a references chain, a docs-layout page and a sidebar-layout controller: 194 tests green, `theme_test` included, 0 `[classes]` warnings, the boosted smoke and the component smoke green, `prove` proven (24 pages, 96 screenshots, show and edit pages through `SMOKE_PATHS`); `test-template.sh generators` and `snippets` keep the byte-identical-after-`destroy`/`remove` checks.
  - **App migration:** *Detect:* `grep -c error_key templates/components/ui.html src/views/helpers.rs` is 0 (or no kit). *Converge:* copy `templates/components/ui.html` and `src/views/helpers.rs`'s `same` and `error_key` (and their test lines) from `.offrails/template/app/`, and `templates/shared/_flash.html`. Pages generated before keep their markup (they're yours); `g resource` of a new feature writes the kit's. *Prove it:* `cargo offrails g resource probes name:string --public --dry-run` refuses nothing, and after a real run and `cargo offrails verify`, `destroy resource probes` leaves `git status` clean.

- **One browser driver for the zero-dependency smokes** (`tools/browser-smoke/cdp.mjs`). `smoke-boosted.mjs` and `prove.mjs` each carried their own copy of the Chrome discovery, the DevTools Protocol pipe and the tab (console errors, CSP, `eval`, `goto`, settle), and `smoke-components.mjs` imported prove's. Now all three import `findChrome`, `requireChrome` (`skip: no Chrome`, exit 77), `launch` (a throwaway profile, headless, Linux CI's `--no-sandbox`, closed and deleted afterwards), `Cdp`, `Tab` (`viewport`, `screenshot`, an `ignore` pattern, the boosted smoke's `phase` prefix) and `signInPost` from it. `selftest-boosted.mjs` (4/4) and `selftest-prove.mjs` pass; the three smokes and `prove` are green against a bare app and one with every part.

- **From a real app's 0.7.1 → 0.7.2 upgrade** (1,530 tests, green after it; six things it tripped on):
  - **Release fixes: a stale `cargo offrails` after an upgrade.** Every file in the release tarball had mtime 2000-01-01, older than an app's cached offrails-gen binary, so Cargo reused the previous release's (`update-part`: "unknown command" until `target/offrails-gen` was deleted). `tools/release/release.sh` now stamps the files with the release commit's time (still deterministic), `install.sh` unpacks with `tar -m` (the files get the time they were unpacked), offrails-gen's `Cargo.toml` version follows `VERSION` (it's in Cargo's fingerprint; `release.sh` refuses a mismatch; the README's release steps say so), and [upgrade](generators/upgrade.md) builds the new release's offrails-gen in `target/offrails-gen-<version>`.
  - **`verify --full` and a coverage toolchain.** With `RUSTUP_TOOLCHAIN` set for cargo-llvm-cov (a toolchain with `llvm-tools`, often without rustfmt or clippy), fmt and clippy failed. That toolchain (or `OFFRAILS_COVERAGE_TOOLCHAIN`) now goes to the coverage step only; every other step runs without it, and `verify` says so (unit test `the_coverage_toolchain_is_for_the_coverage_step_only`; [testing](guides/testing.md#coverage-and-mutation-testing)).
  - **The smokes find the sign-in form.** `smoke-boosted.mjs` and `prove` signed in only when `docs/routes.md` had `POST /session/password`; that app signs in at `POST /session`. They now try when it has `/session/password`, `/session`, `/sessions`, `/login`, `/sign_in` or `/users/sign_in`, or `SMOKE_SIGNIN_PATH` names the route, and submit the password form found on `SMOKE_LOGIN`, whatever its action ([testing](guides/testing.md#browser-smoke-boosted-navigation)).
  - **`run.sh dev-tools`** needed a visible `/dev` link on `/`. It opens `/dev` directly, in a desktop window; each "Send test email" must land on `/dev/mailbox/<id>` with the flash "Sent a test email to your mailbox." and the list must show at least 3 rows (the mailbox's current markup, `#mbx-list`); the jobs steps run only when `/dev` has a test-job button (no part has one now: it says skip) ([dev-tools](guides/dev-tools.md#browser-smoke)).
  - **The template Dockerfile compiled every dependency twice:** its dependency layer had no `.cargo/`, so it and the final build used different rustflags. It now copies `.cargo` before building the dependencies.
  - **`hx-trigger` filters never fire.** `keyup[key=='Enter']` needs eval, which the template's `htmx-config` turns off for the CSP (`allowEval:false`), so the trigger silently does nothing. A gotcha in [frontend-htmx](guides/frontend-htmx.md#gotchas-agents-get-wrong) and CHEATSHEET 28, and `offrails-check [htmx]` warns on a filter in `templates/` (a fixture case in `test-offrails-check.sh`).
  - **App migration:** *Dockerfile.* *Detect:* `grep -c 'COPY .cargo .cargo' Dockerfile` is 0. *Converge:* add `COPY .cargo .cargo` after `COPY Cargo.toml Cargo.lock ./` (before the dependency `cargo build`). *Prove it:* `docker build .` twice after a code change: the second build's dependency layer is cached and its final step compiles only the app. The others arrive with re-vendoring; nothing to converge (an upgrade from 0.7.2 builds the new offrails-gen in `target/offrails-gen-0.7.3` once).

- **Accounts: invite-only is a switch; the `ACCOUNTS` grammar** ([auth](guides/auth.md#variants-accounts-in-the-apps-agentsmd), [authentication](generators/authentication.md#invite-only)). With the parts synced below, auth-core has one switch for every sign-in method: `DEFAULT_SIGNUP` in `src/config.rs` (`Signup::Open` or `Signup::Invite`), overridden per deploy by `APP_AUTH_SIGNUP=open|invite`; invite-only, the sign-up pages answer 404 and their links hide (`ctx.signup_open`), magic links go only to existing accounts, and `app task auth_invite <email>` lets people in. The framework side:
  - **The header names methods, then `invite`:** `ACCOUNTS: password`, `magic-link`, `password+magic-link`, `magic-link invite`, `password+totp invite`. The old words map: `signup` = `password`, `invite` = `password invite`, `magic-link-invite` = `magic-link invite`. The [blueprint](blueprints/AGENTS.md), the [interview](interview.md), the [auth](guides/auth.md) variants table (old anchors kept for the parts' links), [authentication](generators/authentication.md) (invite-only is a switch now, not a list of edits), [template](template/README.md) and [api](guides/api.md) use it. `add-part auth-password` / `auth-magic-link` writes it, adding a method to the ones there and normalizing old words (unit test `the_accounts_header_grammar_and_its_old_words`).
  - **`new --invite`:** sets `DEFAULT_SIGNUP = Signup::Invite` and `ACCOUNTS: … invite`; refused without `auth-password` or `auth-magic-link` among the parts.
  - **App migration:** *Detect:* `grep -E '^ACCOUNTS: +(signup|invite|magic-link-invite)\b' AGENTS.md`. *Converge:* rewrite the words as above; an invite-only app also sets `DEFAULT_SIGNUP = Signup::Invite` in `src/config.rs` and drops any hand-made invite-only edits the old [authentication](generators/authentication.md) told it to make (restored `/registration*` routes and links are hidden by the switch). *Prove it:* `sh .offrails/tools/offrails-check` passes `[header]`; invite-only: `curl -s -o /dev/null -w '%{http_code}' $URL/registration/new` prints 404.

- **Parts synced from samples `2bc93ad`** (`tools/sync-parts.sh`): two auth-magic-link tests create their account first, so they pass in invite-only apps too (samples CI now reruns the auth suites with `DEFAULT_SIGNUP = Signup::Invite`); dev-dashboard's `jobs_are_traced_with_their_sql` picks its trace by request id and prints every trace it held if it fails (reported flaky once in CI, not reproduced); invite-only as the switch above (`auth_invite` task, `Signup`, `SignupOpen`, `ctx.signup_open`); part tests that use `paths::after_sign_in()`/`paths::account()` instead of a hard-coded `/account`; the dev mailbox's code detection (no more "CODE 2026"); the dev dashboard's AI briefing summarized on load; phone widths: a `.scroll` box contains its table's visually-hidden labels in `dashboard.css` and `jobs.css`, and every dashboard tab, the jobs pages, `/dev` and the mailbox fit 390 px. The same containment for the template's own `static/kernel/admin.css` (`.scroll { position: relative; max-width: 100% }`): `prove` on the app with every part reports 0 kernel pages with warnings (it listed `/admin/jobs`, `/admin/jobs/cron` and the dashboard's requests and lab pages before). *App migration (admin.css):* *Detect:* `grep -n '^\.scroll' static/kernel/admin.css` shows no `position: relative`. *Converge:* `.scroll { position: relative; max-width: 100%; overflow-x: auto; }`. *Prove it:* `prove` reports no sideways scroll at 390 px.
- **Dev-dashboard flakes fixed at their cause; parts synced from samples `393408b`.** Three dev-dashboard tests failed once and passed on retry in this CI (`a_trace_expands_inline_with_repeats_folded`, `jobs_are_traced_with_their_sql`, `the_checkout_fan_out_traces_four_upstreams`; reproduced locally under parallel load, about 1 run in 300), and a fourth under the same load locally (`every_tool_answers_within_its_cap`: `cap()` kept 60 characters for its 61-character cut note, so a trace that filled the room came out one over; fixed, with a test at the boundary). Not shared on-disk state: other threads hold clones of a span, and a span closes at its last clone's drop. The SQLite driver's worker thread keeps the span each query ran in until its next command; with the tests' thread-local subscriber (`set_default`), a span dropped last there closed its parent through that thread's (empty) default, so the handler and the methods above the last query never closed and the waterfall had no handler row. hyper spawns the connection a request opens with the current span and pools it, so an outbound call that opened a connection lasted as long as the connection and overlapped the next calls; and the sequential-calls check wanted under 5 ms of wall clock between two calls, which a loaded machine exceeds with nothing in between. Fixed in the part: the tests' `traced()` installs the subscriber as the process's global default (as `main` does); a request trace records the spans still open when its response is done (`trace::flush_open`; a counter per span says whether to look); `kernel::outbound` sends from a task of its own (aborted with the call), so the pooled connection holds no span; calls are sequential when nothing of the handler's (a query, a method, a template) ran between them (`SEQUENTIAL_GAP_US` is gone); the job test pings the database (the SQLite worker answers only after it let go of the job span) before it looks. `test-template.sh`'s `KNOWN_FLAKY` is empty: its two entries (`run_history_is_capped_redacted_and_reopened`, `bench_drives_a_running_server`) shared `storage/dashboard/`, which the part already keeps per test process, and neither has flaked in CI since; the gate (one retry, any flaky test not listed fails the run) stays. *App migration (apps with the dev-dashboard part):* *Detect:* `grep -c flush_open src/kernel/dashboard/trace.rs` is 0. *Converge:* `cargo offrails update-part dev-dashboard`. *Prove it:* `cargo offrails verify --fast`.

- **Docs, one story across both branches:** start from a snippet or a generator (both optional), build pages from the component kit in a layout, prove with `cargo offrails prove`. AGENTS.md (prime directive 2 and the load table), CHEATSHEET (the kit, the layouts, `brand`), [workflow](workflow.md) (BUILD and VERIFY), [template](template/README.md), [README](README.md), [generators](generators/README.md), [scaffold](generators/scaffold.md), [controller](generators/controller.md), [views](guides/views.md) (Layout & partials and Forms rewritten on the kit; the stale layout listing, its per-page head scripts and `_pagination.html` are gone), [testing](guides/testing.md), [models](guides/models.md). `tools/ci/check-links.py` counts `<a id="…"></a>` anchors (kept for renamed headings): 0 broken links; every shellcheck line in CI is clean.

## 0.7.2 (2026-10-18)

### What's new

- **`cargo offrails verify`** runs the Definition of Done in parallel in one command. `--fast` (after every change) runs fmt, clippy and the SQLite and Postgres suites at the same time; `--full` (once, at the end) adds coverage, `cargo deny`, gitleaks and the other checks. A bare app's loop after a one-line change went from 140 s to about 6 s.
- **Generators:** `cargo offrails generate <kind>` (`g`) writes migrations, models, CRUD resources, controllers, pages, APIs, jobs, mailers, channels and tests that follow the guides and come with their tests. `cargo offrails destroy` (`d`) removes exactly what was generated and is still unchanged.
- **`cargo offrails update-part`** brings a newer version of an installed part into the app with a three-way merge: clean changes apply, overlapping edits get conflict markers, and the app's own changes are never silently overwritten.
- **Faster Postgres tests:** the suite migrates once into a template database and reuses locked slot databases reset before each test, instead of creating and migrating a database per test.
- **A browser smoke for boosted navigation** catches page JavaScript that works on a fresh load but is dead after a boosted link. Page scripts now load from the layout and bind by delegation.
- **Template speed-ups:** one CI test job instead of two, a cached Docker dependency layer, and a cached development CSS check.

### Breaking changes

Nothing is required to keep an existing app working. Before using the new tools:

- Generators need the `// samples:` anchors in the shared files; a generator that lacks one refuses and names it.
- The route-sweep tests (`smoke_test.rs`, `security_test.rs`) must send each route's own method before the first `cargo offrails g api`.
- Parts updated to this release load their page scripts at a new `samples: app head` anchor in the layout.
- `split-debuginfo` left `[profile.dev]` in the template; an app built on Windows must remove it too.

### Upgrading

Follow the ordered list below; each step's Detect/Converge/Prove is in the entry it names under Full list of changes, and [upgrade](generators/upgrade.md) is the whole procedure. `$FW` is the new framework.

1. **Parts first, before re-vendoring:** `update-part` merges against the part as installed in `.offrails/`, so run it while `.offrails/` is still the old release: `cargo offrails update-part <each installed part> --framework "$FW" --dry-run`, then without `--dry-run`. Resolve any `<<<<<<<` markers, then `cargo fmt --all` and `cargo run -q -- db migrate`. (An app on 0.7.1 or older has no `update-part` yet: run the new release's, `cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" -- update-part <part>… --framework "$FW" --app .`.)
2. **Re-vendor:** `offrails-gen vendor --framework "$FW"`. `cargo offrails verify`, `generate`, `destroy` and `update-part` now work.
3. **Generator anchors** (Generators → App migration): add every missing `// samples:` anchor. A generator that needs a missing one refuses and names it, so this can wait until the first `generate`.
4. **Route sweeps send each route's own method** (same entry): required before the first `cargo offrails g api`.
5. **Optional speed-ups** (Fast loop → App migration): Postgres test slots, the CSS check cache, the `split-debuginfo` move (needed for Windows builds), and the one-job CI.
6. **Prove it:** `cargo offrails verify --full` is green on SQLite and Postgres, `sh .offrails/tools/offrails-check` reports 0 errors, and `docs/decisions.md` has `YYYY-MM-DD: upgraded to framework 0.7.2` plus the generators line.

### Full list of changes

- **A fast loop: `cargo offrails verify`** ([workflow](workflow.md#definition-of-done)). A human, after a 41-minute agent build and 4+ minute CI runs: "we need CI to run in 10 seconds or less, can't we parallelize or something". The Definition of Done ran sequentially (fmt, clippy, SQLite suite, Postgres suite, coverage, deny, gitleaks, browser smokes), and the docs had agents run all of it after every change. Now one cross-platform command (in `offrails-gen`, so `cargo offrails verify` works in every template app) runs it in parallel:
  - `--fast` (default; after every slice): `cargo fmt --check`, the CSS build (htmx), clippy `-D warnings` in its own target dir (`target/clippy`, so it compiles while the tests build), the test binaries once, then the SQLite and Postgres suites **at the same time** (nextest; Postgres runs the integration tests, since unit tests never read `TEST_DATABASE_URL`). One line per step with its time and test counts, the tail of each failure, logs in `target/verify/`, non-zero exit on any failure. Postgres comes from `TEST_DATABASE_URL` (environment or `.env`) or the compose URL when `localhost:${DB_PORT:-5432}` answers; otherwise it says **skipped**.
  - `--full` (once, at the end): also `offrails-check`, coverage (it *is* the SQLite run, instrumented with `cargo llvm-cov nextest --fail-under-lines 80`), `cargo deny`, gitleaks, the no-`unwrap`/`expect` review, `routes` vs `docs/routes.md`, the TS checks when there is a `web/`, and the browser smoke when `BASE` names the running app. Missing tools are reported as `skip` with the install command, never as passed.
  - The guidance changed with it: [workflow](workflow.md) (the loop's VERIFY step, the Definition of Done, "iterate fast, verify fully once": one test with `cargo nextest run <name>` while red, `verify` after each slice, coverage/deny/gitleaks/full browser smokes only in `--full`), CHEATSHEET, `/offrails:check`, the app `AGENTS.md` blueprint, [new](generators/new.md) step 5, `offrails-gen new`'s next steps, `tools/ci/test-template.sh` (now runs `verify`, still failing on any flaky test outside its known list).
  - Measured on generated apps (Apple M2, 8 cores, macOS; other agents were compiling on the same machine the whole time, so each row gives the load average, and old and new were run back to back under the same load). "Old" is 0.7.1's template and its sequential Definition of Done; "new" is this release.

    | Step | Bare, old | Bare, new | Every part, old | Every part, new |
    |------|-----------|-----------|-----------------|-----------------|
    | Cold build (`cargo build`, then the test targets) | 217 s + 38 s (load 22–45) | same dependencies, same | 180 s + 59 s (load 31–53) | same |
    | Cold build, a second app on the machine | 293 s (load 121) | 128 s with sccache (opt-in; load 127) | n/m | n/m |
    | No-op rebuild of the tests | 0.5–0.7 s | same | 0.65 s | same |
    | One-line change, test build | 3.7–5.4 s (load 40–65) | 3.2–3.8 s inside verify (load 11) | 12.3 s (load 36) | 12.2–15.9 s inside verify (load 9–11) |
    | Clippy after a one-line change | 1.9 s | 1.1–2.3 s, beside the build | 8.1 s | 7.7–9.5 s, beside the build |
    | SQLite suite | 1.3 s (121 tests) | 1.4 s | 8.0–10.4 s (652 tests) | 8.5–9.5 s |
    | Postgres suite | 8.0 / 11.9 s (load 15–18) | 1.7 / 1.8 s | 149 / 46 s (load 12–37) | 21 / 14 s (load 17–20) |
    | The fast loop: fmt, clippy, test build, SQLite, Postgres after a one-line change | 140 s, sequential (load 19–33) | **5.5–6.7 s** (`verify`, load 11) | 76–179 s, sequential (sum of the rows above) | **33.5–36.5 s** (`verify`, load 9–11) |
    | The whole Definition of Done after a one-line change (+ offrails-check, coverage, deny, gitleaks) | 157.7 s (load 19–33) | **20.1 s** (`verify --full`, load 14–18) | not run (the instrumented build needs another ~5 GB; the disk was full) | — |

    Where an all-parts app's 34 s go: the test build (~12 s, the app crate compiled as lib and as lib-test in parallel, then the test binary), then the Postgres suite (~20 s beside SQLite, ~14 s alone; 461 integration tests, the longest single tests 4–5 s of real timeouts in the parts' own tests). The 10 s target holds for the bare app, not for one with every part.
- **Postgres tests: no `CREATE DATABASE` and no migrations per test** ([testing](guides/testing.md#postgres-test-databases)). Each test used to create `test_<crate>_<uuid>`, migrate it and drop it. `CREATE DATABASE` copies and fsyncs a template (measured on this machine under load: 1.2–3.8 s for one empty database), so the Postgres run degraded badly when the machine was busy (a 541-test blog: 42.5 s quiet, 335 s at load ~68). Now `support.rs` migrates once into `test_<crate>_<tag>_tpl` (`<tag>`: a hash of the migration names and files, so an edited migration builds a new one; built under an advisory lock and renamed into place, so concurrent first tests wait and a killed build leaves nothing half-migrated), each concurrent test locks a slot `test_<crate>_<tag>_<n>` (`pg_try_advisory_lock`), cloned from the template once and reused across tests and runs, with `synchronous_commit = off`, and `offrails_test.reset()` puts the slot back to the just-migrated state before every test: tables with rows truncated, the migrations' seed rows and every sequence restored. A schema fingerprint makes the reset refuse a slot a test changed (rolled a migration back, created a table), and the harness re-clones it. Still every test on both databases, isolated. Also evaluated: a rollback transaction per test (rejected: the app's own transactions, the job worker and realtime use other connections) and `CREATE DATABASE … TEMPLATE` per test (keeps the fsync cost). SQLite stays in-memory and migrated per test: the migrations are ~5 ms of a ~40 ms median test. The new SQL is in `kernel::db` (`database_exists`, `create_database_from`, `rename_database`, `advisory_lock`/`try_advisory_lock`/`advisory_unlock`, `skip_commit_flush`, `install_test_reset`/`reset_test_database`, `drop_idle_stale_test_databases`), with a kernel test (`the_postgres_test_reset_empties_tables_and_refuses_a_changed_schema`).
- **The development CSS check is cached** ([views](guides/views.md#dark-mode)). `kernel::css::css_warning` read every template and the built CSS on every development page (~85 ms in a grown app; 97% of the CPU under load). It now recomputes only when a template, `assets/app.css` or `static/app.css` changes (size and mtime), with a unit test.
- **Template CI: one test job** ([testing](guides/testing.md#ci)). The `sqlite` and `postgres` jobs each compiled the tests; the new `test` job runs `cargo offrails verify --fast` against a Postgres service on tmpfs, with rust-cache keeping `target` and `target/clippy`, after the migration guard, `offrails-check` and a `cargo metadata --locked` check. The Docker job's `Dockerfile` builds the dependencies in a layer of their own (a placeholder `src/`), which the `gha` layer cache keeps; the old `--mount=type=cache` target was never in that cache, so every run compiled every dependency in release mode. Locally (Docker on the M2, bare app, load 18→77): the first image build 352 s, a rebuild after a one-line code change 156 s with the dependency layer reused (the app crate's release compile and the CSS), and the image boots (`/up` 200). Not measured on GitHub (no runner available to this change): expected, from the local split, that a code-only push skips the release dependency build (most of the Docker job) and compiles the tests once instead of twice.
- **A dev-dashboard test failed for good after ~50 runs in one working copy** (`run_history_is_capped_redacted_and_reopened`). It saved a run dated 2027 into the real `storage/dashboard/ai-runs/` and never removed it; once `MAX_HISTORY` of them piled up they outranked and pruned the run the test makes. It now deletes its file. A local change to `template/parts/dev-dashboard` (the samples repo needs the same one-line fix; `tools/sync-parts.sh` would undo it). Its other flake (another test process pruning the shared history) remains in `test-template.sh`'s known list.
- **Build settings** ([testing](guides/testing.md#build-speed)). `split-debuginfo = "unpacked"` left `[profile.dev]`: Windows MSVC refuses it, and it is macOS's default anyway; Linux gets it from `.cargo/config.toml` (`[target.'cfg(target_os = "linux")']`). Faster linkers stay opt-in per machine (`~/.cargo/config.toml`; x86_64 Linux already uses rust-lld since Rust 1.90). sccache is documented as an optional shared cache for a second app on one machine; a shared `CARGO_TARGET_DIR` is not recommended (one build lock for every app and agent).

  **App migration** (optional; every item makes an existing app faster, none is required):
  - *The verify command.* *Detect:* `cargo offrails help | grep -c verify` is 0, or `.offrails/tools/offrails-gen/src/verify.rs` is missing. *Converge:* re-vendor (`offrails-gen vendor --framework "$FW"`, the upgrade does this); the alias in `.cargo/config.toml` already runs the vendored `offrails-gen`. Apps without the alias add it from `.offrails/template/app/.cargo/config.toml`. *Prove it:* `cargo offrails verify` prints a line per step and `green`.
  - *Postgres test slots.* *Detect:* `grep -c offrails_test src/kernel/db.rs` is 0, or `tests/app/support.rs` still calls `create_database` per test. *Converge:* copy from `.offrails/template/app/src/kernel/db.rs` the functions from `drop_idle_stale_test_databases` through `TEST_RESET_SQL` (keep the app's own changes to the rest of the file), and replace in `tests/app/support.rs` everything from `pub struct DbGuard` to `impl TestApp {` with the template's (`DbGuard`, `PgSlot`, `test_database`, `checkout_postgres_slot`, `test_db_prefix`, `fnv1a`, `migration_tag`, `database_url`, `lock_slot`, `ensure_template`). `TestApp` keeps its `db_guard` field and `on_sqlite()`. If the app's `support.rs` creates databases elsewhere (a second pool, a fixture), route it through `test_database()`. Copy the kernel test from the template's `tests/app/kernel_test.rs`. Then `app task test_db_cleanup <admin url>` once to drop the old `test_<crate>_<hex>` leftovers. *Prove it:* the suite on Postgres twice (the second run creates no database: `psql -Atc "select count(*) from pg_database where datname like 'test_%'"` is unchanged), a test that runs `Migrator::down` still passes, and the kernel test passes on both databases.
  - *CSS check cache.* *Detect:* `grep -c inputs_key src/kernel/css.rs` is 0. *Converge:* port `css_warning`, `cached_warning`, `inputs_key`, `html_files_under` and the test from the template's `src/kernel/css.rs`. *Prove it:* `cargo nextest run the_css_warning_is_recomputed_only_when`.
  - *Profiles.* *Detect:* `grep -n split-debuginfo Cargo.toml`. *Converge:* remove that line from `[profile.dev]` and add the Linux `[target.'cfg(target_os = "linux")']` block from the template's `.cargo/config.toml`. *Prove it:* `cargo build` (and on Windows, a build that no longer stops at `-Csplit-debuginfo`).
  - *CI.* *Detect:* `.github/workflows/ci.yml` has separate `sqlite` and `postgres` jobs. *Converge:* replace them with the template's `test` job (keep the app's own extra steps, such as the TS steps, after `verify`), and take the `Dockerfile`'s dependency layer (rename `offrails_app` to the crate). *Prove it:* the workflow is green on the next push and its `test` job shows one compile.

- **Generators you run, not recipes you copy: `cargo offrails generate <kind> …` (`g`) and `cargo offrails destroy <kind> <name>` (`d`)** ([generators](generators/README.md)). The human: "can we add actual code generators similar to rails? so the agent can invoke those instead of just copying? like db migrations, new controller, new api route, new page, and especially the parts… i hope this would make things even faster." Kinds: `migration` (`create_x` / `add_y_to_x` / `remove_y_from_x` / blank, each with a working `down`), `model` (migration + entity + `Params` → `validate()` → `New` + scoped queries + factory + model tests), `resource` (alias `scaffold`: the model plus htmx CRUD with the seven actions, forms that re-render with errors and the input, flash, nav link, request tests), `controller`, `page`, `api` (`/api/v1` with bearer tokens and scopes, `ApiJson`, `{"data": …}` + page info, OpenAPI via utoipa with a drift test, and the API kernel on first use), `job`, `mailer` (HTML + text, previews), `channel` (a realtime topic with authorization) and `test`. Field types: `string text integer bigint boolean decimal{p.s} money datetime date uuid json references{model}` with `?`/`:optional`, `:unique`, `:index`, each mapped to its column, Rust type, form control, validation and factory value. Generated code follows the guides exactly: 64-bit ids and FKs (`offrails-check [ids]`), per-user scoping when the app has accounts (`find_for`, raw `find` model-private, another user's id is 404, unique-per-owner indexes, references checked against the actor), `Access` rows, `paths::` helpers, docs/routes.md, `#[instrument(skip_all)]` on every action and model fn, no `unwrap`/`expect` outside tests, templates in the design tokens with every `dark:` partner, and request tests for happy paths, 422s, sign-in, tenancy (`guard_test.rs` + an anchored `tenancy_test.rs`), escaping and the N+1 guard, on SQLite and Postgres. Without accounts, `model`/`resource` refuse until you add them or pass `--public`.
  - **Safe to run:** everything is rendered in memory first (a missing anchor is a refusal naming the file and spot; nothing written), `--dry-run` prints the plan, re-running is a no-op, `--force` regenerates only over files nobody changed, and `docs/generated.toml` records each generation (arguments, choices, formatted-file hashes) so `destroy` removes exactly the unchanged files and wiring and reports what it kept. `rustfmt` runs afterwards; next steps are printed (migrate, Tailwind, `cargo offrails verify --fast`, what to customize: the `// APP:` lines).
  - **Parts are first-class in the same CLI:** `cargo offrails parts` (alias of `list-parts`), `cargo offrails add <part>` (alias of `add-part`), and new **`cargo offrails update-part <part>… --framework <newer>`**: a three-way merge per file (the part as installed in `.offrails/`, the newer part, the app's copy): clean hunks apply, overlapping edits get `<<<<<<<`/`|||||||`/`=======`/`>>>>>>>` markers and exit 1, changed wiring is swapped where the old text stands; an app change is never silently overwritten. `add-part` and `new` now refresh docs/routes.md (the first `offrails-check` no longer warns `[routes]`).
  - **Measured** (macOS, Apple silicon, 8 cores, load average ~20 from other agents, cold target dir, warm crates.io cache): a blog-equivalent built only with generators (`new --parts auth-core,auth-magic-link,jobs`, then `g resource posts title:string slug:string:unique body:text 'published_at:datetime?'`, `g page about`, `g mailer post_mailer published`, `g job notify_post_published post_id:bigint --queue mailers`): `new` 8 s, the four generators 4 s, then green on SQLite after 200 s (the first compile; 357 tests in 4.8 s) and on Postgres 9 s later (7.7 s), `offrails-check` 0 errors: **221 s from nothing to green on both databases**. The generators wrote 1,865 lines in 38 files (migration, model with validation and scoped queries, controller, views, 5 templates, routes, factory, model/request/tenancy/guard tests, page, mailer with previews, job), with 24 `// APP:` decisions marked. The 0.7.1 blog's hand-written equivalent of those files was ~2,100 lines; what an agent still writes for that blog is the domain on top (public reading by slug, publish/unpublish, slug from the title, excerpt and reading time, Markdown, the feed, the mail's words, enqueueing the job in the publish transaction, the spec's own tests): roughly 700–900 lines, by the blog's own counts.
  - **App migration:** existing apps get the generators by re-vendoring (`.offrails/tools/offrails-gen`); `cargo offrails g --help` works at once. Then:
    - **Detect** the anchors generators need: `grep -n "samples: \(model mods\|model aliases\|controller mods\|view mods\|routes\|paths\|routes table\|test mods\|factories\|migration mods\|migrations\)" src/models/mod.rs src/controllers/mod.rs src/views/mod.rs src/routes.rs tests/app/main.rs tests/app/factories.rs migration/src/lib.rs` (and `nav links` in `templates/shared/_nav.html`, `sitemap paths` in `src/controllers/meta.rs`, `test support` in `tests/app/support.rs`). **Converge:** add each missing one on a line of its own where [new.md#anchors](generators/new.md#anchors) says; a generator that lacks one refuses and names it. `migration/src/lib.rs` rewritten by `sea-orm-cli` is healed automatically; from now on generate migrations with `cargo offrails g migration`, never `sea-orm-cli migrate generate`.
    - **Detect** `tests/app/smoke_test.rs` / `security_test.rs` sweeping `ROUTES` with `app.post_form` for every non-GET row. **Converge** (needed before `generate api`, whose routes use `PATCH`/`DELETE`): copy `TestApp::request_method` and `send_json` from `.offrails/template/app/tests/app/support.rs` into `support.rs`, and send each row's own method (`other => app.request_method(other, &path).await`) as the template's two tests now do. (`generate api` inserts `send_json` itself when it's missing.)
    - **Detect** a hand-written `tests/app/tenancy_test.rs` without `// samples: tenant fill`. **Converge:** nothing required; `generate resource` then prints the `Tenant`/`fill` lines to add for each new resource (or replace the file with the generated one, keeping your `EXPECTED` rows).
    - Agents: build features with generators from now on (AGENTS.md prime directive 2; workflow loop step 3). Log in `docs/decisions.md`: `YYYY-MM-DD: generators adopted (framework 0.7.2): new features start from cargo offrails generate`.
  - **Changed docs:** [generators/README](generators/README.md) (the command table, field types, what generators decide), new [migration](generators/migration.md), [controller](generators/controller.md) (and pages), [api](generators/api.md) recipes; [scaffold](generators/scaffold.md), [job](generators/job.md), [mailer](generators/mailer.md) (sends from a job with `state.mailer.deliver`, no nonexistent `deliver_later`), [channel](generators/channel.md) start with the command; [new](generators/new.md) (build features with generators after the parts; generator anchors), [upgrade](generators/upgrade.md) (step 6: `update-part` before re-vendoring), AGENTS.md (prime directive 2: generate before hand-writing), CHEATSHEET, [workflow](workflow.md) (PLAN/GENERATE), [RAILS_MAP](RAILS_MAP.md) (`rails generate` ↔ `cargo offrails g`, `rails destroy`), [cli](cli.md), [structure](structure.md), [template](template/README.md), the `generate` and `scaffold` skills.
  - **Template:** `TestApp::request_method` (the route sweeps send each row's own method) and `TestApp::send_json` (JSON with headers, no cookies).
  - **CI:** `tools/ci/test-template.sh generators` generates every kind into a bare app (a `--public` references chain, a resource with every field type, page, controller, migrations) and into an app with every part (scoped resources, api, job, mailer, channel, model, test), runs fmt, clippy, the suite on SQLite and Postgres and offrails-check, re-runs every generator (no-op), destroys everything in reverse and checks the app is byte-for-byte the baseline; `update-part` merges a synthetic newer part cleanly and then reports a conflict with markers. `offrails-gen` has golden files for every generator (`tools/offrails-gen/testdata/golden/`, `UPDATE_GOLDEN=1 cargo test golden`).
- **A browser smoke for boosted navigation: every page must work after a boosted link, not only on a fresh load** ([frontend-htmx](guides/frontend-htmx.md#boosted-navigation), [testing](guides/testing.md#browser-smoke-boosted-navigation)). The layout boosts every link, and a boosted click swaps `<body>` and runs nothing from the next page's `<head>`; `DOMContentLoaded` never fires again. The sign-in page's password **Show** button worked on a direct load and was dead after clicking through to the page: `auth.js` loaded in the page's `{% block head %}`. Request tests and screenshots can't see it.
  - **`.offrails/tools/browser-smoke/smoke-boosted.mjs`**: for each GET route in `docs/routes.md` without a parameter that is a boosted HTML page (kernel pages, JSON and streams are skipped and listed; a route that redirects smokes the page it lands on), it clicks the page's live elements after a full load and records which react (DOM or attribute change, print, copy, dialog), then loads `/`, follows a boosted link to the page and clicks them again. It fails on an element that is dead after the boosted link, on a script or stylesheet the page loads in its own `<head>`, and on any console error, uncaught exception or CSP violation on either path. With password sign-in it runs signed out, then signed in as the demo account. Zero dependencies: Node ≥ 18 and the installed Chrome, Chromium or Edge (`CHROME_PATH`, else the usual places on macOS, Linux and Windows), driven over the DevTools Protocol on a pipe; it never downloads a browser. Without one it prints `skip: no Chrome` and exits 77.
  - **The live-element convention:** every element page JS reacts to on click is a `<button type="button">` or carries `data-js` (or `data-toggle`, `role="button"`), and the JS binds by delegation on `document`.
  - **`cargo offrails verify --full`** with `BASE` set runs it as `browser smoke (boosted)` beside the kernel-page smoke; exit 77 is reported as `skip`, never passed (`Step::skip_on`, with a test). `run.sh` is POSIX sh now and has `run.sh boosted`.
  - **Template:** the layout has a `samples: app head` anchor (a part's scripts load on every page), `static/app.js` states the rule, and `tests/app/smoke_test.rs` has two static guards: `app_pages_load_no_scripts_or_stylesheets_in_their_head_block` and `page_scripts_never_bind_only_on_dom_content_loaded`.
  - **Parts:** the parts synced from samples ee71024 load their page scripts at `samples: app head` (auth-core's `auth.js`, realtime's `channels.js` and `live_room.js`, the dev-dashboard's browser beacon) instead of page head blocks; that wiring needs the anchor above.
  - **CI:** `tools/ci/test-template.sh` serves the bare and the all-parts apps (development, their own SQLite file, the demo account seeded) and runs the smoke against them; without Chrome or Node the summary says `browser smoke SKIPPED`. The framework's CI runs `tools/browser-smoke/selftest-boosted.mjs`, which serves a tiny htmx site and proves the smoke passes a delegated Show button and fails a head-block script binding on `DOMContentLoaded`, a layout script binding on `DOMContentLoaded`, and a console error.
  - **Measured** (M2, load 13–25): the all-parts app, 18 page visits signed out and in, every Show and Copy button on the auth pages clicked on both paths, 25–28 s; the bare app (one page, `/`) 4.7 s; the selftest's four cases 25 s. Reintroducing the bug in an all-parts app (auth.js back in `sessions/new.html`'s head block) fails it with `/session/new (signed out): loads /static/auth.js in its <head>…` and `button[data-reveal="signin_password"] "Show password" reacts on a full load and is DEAD after a boosted link`; putting it back passes.
  - **App migration:**
    - *Page scripts.* *Detect:* `grep -rln "{% block head %}" templates | xargs grep -l "<script\|stylesheet"`, and `grep -rln DOMContentLoaded static --exclude-dir=vendor`. *Converge:* move each page's head-block `<script>`/stylesheet into `templates/layouts/application.html` (after `app.js`; add `{# samples: app head #}` on a line of its own there if missing), digested through `ctx` like `app_js`; rewrite a script that binds elements on `DOMContentLoaded` as delegated listeners on `document` (`e.target.closest("[data-…]")`), or also start it on `htmx:load`. For the auth and realtime parts, `cargo offrails update-part auth-core` (and `auth-password`, `auth-totp`, `realtime`) brings the samples' fix. *Prove it:* copy the two tests from the end of `.offrails/template/app/tests/app/smoke_test.rs` (with `files_with_extension`) into `tests/app/smoke_test.rs`; both pass.
    - *The smoke.* Re-vendor (the upgrade does). *Prove it:* with `bin/dev` running, `BASE=<its URL> node .offrails/tools/browser-smoke/smoke-boosted.mjs` ends with `0 failure(s)` (seed the demo account first with `cargo run -- task auth_demo` when the app has password sign-in). Log `YYYY-MM-DD: page JS loads from the layout and binds by delegation (framework 0.7.2, boosted navigation)` in `docs/decisions.md`.

## 0.7.1 (2026-10-17)

### What's new

- **New apps start from a tested template.** `offrails-gen new` copies a complete, compiling htmx app (the whole kernel, tests, CI, Dockerfile and compose) instead of an agent retyping it from Markdown. A fresh app is green on SQLite and Postgres in 2 min 53 s, most of it the first compile.
- **Parts:** the mailer, dev mailbox, jobs, jobs dashboard, sign-in methods, realtime and dev dashboard ship as installable parts. The agent proposes the ones the product needs, and `cargo offrails add-part <name>` adds one later (it changes nothing if an anchor is missing). `cargo offrails list-parts` shows what is installed.
- **Cross-platform tooling:** `offrails-gen` is a standard-library-only Rust tool, so `new`, `add-part`, `list-parts` and `vendor` run the same on Linux, macOS and Windows.
- **Lean memory defaults:** 2 worker threads, 16 blocking threads and a smaller SQLite pool and cache. A bare release build uses 15 MiB after 1000 requests, and CI fails a build over its memory budget.
- **Tracing spans by default** on template actions, so the dev dashboard's traces work out of the box.
- **Upgrades can diff the old template against the new one** to see every kernel change in a release.

### Breaking changes

None. Existing apps keep working; the template alias, the memory settings and the parts are optional to adopt.

### Upgrading

Optional: the **App migration** note at the end of the first entry below (compare with the template, add the `cargo offrails` alias, the memory settings). The whole procedure is in [upgrade](generators/upgrade.md).

### Full list of changes

- **New apps start from a tested template, not from Markdown retyped token by token** ([template](template/README.md), [new](generators/new.md)). The framework now ships its app skeleton as real files: `template/app/` is a complete, compiling app (htmx) with the whole kernel (config, errors, the security header matrix and CSP, CSRF, cookies and flash, the development Host allowlist and 127.0.0.1 bind, rate limiting, log and error-text redaction, SQLite + Postgres pools with per-test Postgres databases, introspection, `/dev` with the pending-migrations guard, the theme switch, direct TLS), the layout, error pages, Tailwind, `bin/`, the migration crate, `tests/app/` with every kernel suite, `deny.toml`, CI, Dockerfile and compose, and every `samples:` anchor. It was the samples repo's `host/`, proven on both databases, and the framework's from now on. Building the skeleton from 0.7.0's Markdown cost an agent 50+ minutes; generating it takes seconds, and a fresh app is green on SQLite and Postgres in 2 min 53 s from the generator's start (5 min 46 s with every part; macOS, Apple silicon, 8 cores, cold target dir, warm crates.io cache), most of it the first compile. The human's decision: "add the full skeleton as templates… we are not a codeless framework anymore but it's fine." The guides stay the spec for *why*; [doctrine §7](doctrine.md#7-markdown-is-the-spec-the-template-is-its-rendering) is rewritten to match: agents copy the template, adapt it and own it; humans still write no code.
  - **How:** after the interview, `cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" --target-dir <dir>/target/offrails-gen -- new <dir> <crate_name> "<Display Name>" --pitch "<line>" --parts <a,b>` (or `sh "$FW/tools/new-app.sh" …`). It copies the template, renames its tokens (`offrails_app`, `offrails-app`, `Offrails App`), vendors `.offrails/`, writes `AGENTS.md` and `product/README.md` from the blueprints, adds the parts, runs `cargo fmt` and `git init`s.
  - **Parts, proposed by default.** The base is lean: no job queue, mailer, accounts or realtime. Those are **parts**, the samples from [rust-off-rails/samples](https://github.com/rust-off-rails/samples) vendored into the release (`template/parts/`). The agent proposes the ones the product implies, using a decision table ([template → choosing parts](template/README.md#choosing-parts): `dev-dashboard` for every app, `mailer` + `dev-mailbox` for email, `jobs` + `jobs-dashboard` for slow or scheduled work, `auth-core` + a method for sign-in, `realtime` for pushed updates), shows the human the samples link (screenshots of every screen), and the human confirms or opts out. This flips 0.7.0's head start, whose recommended answer was "build from the guides": a real run of a blog skipped every sample because of it.
  - **Easy to add later:** `cargo offrails add-part <name>` in any template app (the alias in `.cargo/config.toml`), with dependencies first, checks before changes (a missing anchor or a conflicting file refuses and changes nothing), the `docs/decisions.md` line, the `AGENTS.md` header (`EMAIL`, `ACCOUNTS`), `cargo fmt`, and the exact next steps. Re-running is a no-op. `cargo offrails list-parts` shows installed vs available. It is the samples' `tools/install.sh` mechanism in Rust (same format, same record line), and `new --parts` uses the same code path. [workflow](workflow.md#when-the-product-grows-a-need-add-the-part) and every guide with a part (mailers, jobs, jobs-dashboard, auth, channels, realtime, dev-tools, errors-and-logging) now say: when the product grows the need, add the part; check `list-parts` before hand-writing kernel code.
  - **Cross-platform generators.** `tools/offrails-gen` is a standard-library-only Rust tool (`new`, `add-part`, `list-parts`, `vendor`), so it runs the same on Linux, macOS and Windows: no `sed -i`, paths through `std::path`, CRLF kept where a file has it. The template and the framework carry `.gitattributes` (`* text=auto eol=lf`). The app's `bin/` scripts and `offrails-check` remain POSIX shell; Windows steps and the known gaps are in [template → Windows](template/README.md#windows).
  - **Lean memory defaults, measured.** `main` builds the Tokio runtime from `Config`: `APP_WORKER_THREADS` (default 2) and `APP_BLOCKING_THREADS` (default 16) instead of one worker per core and 512 blocking threads; SQLite gets `cache_size` 1 MiB per connection, a default pool of 4 (`DATABASE_POOL`; Postgres keeps 10) and a 60 s idle timeout ([database](guides/database.md#sqlite-memory), [config](guides/config.md#runtime-threads-app_worker_threads-app_blocking_threads)). `tools/ci/memory-budget.sh` boots a release build in production mode, serves 1000 requests and fails over budget. Measured on macOS (Apple silicon, 8 cores): bare, release, 14 MiB RSS after boot and 15 MiB after 1000 requests (debug 16 / 17); every part, release, 18 / 20 MiB (debug 24 / 26). With the old thread and pool settings the same runs were 16 and 21 MiB after load: the saving is small on an 8-core laptop and grows with the core count. Budgets: 24 MiB bare, 32 MiB with every part.
  - **Spans by default.** Template actions carry `#[tracing::instrument(skip_all)]`, and the guides make it the convention for actions and model fns, with the rule "never put secrets or personal data in span fields" ([errors-and-logging](guides/errors-and-logging.md#spans)), so the dev dashboard's traces work out of the box.
  - **Upgrades:** an app can diff `.offrails/template/app` (old) against `$FW/template/app` (new) to see every kernel change of a release, as a reference to port, never an overwrite ([upgrade](generators/upgrade.md#3-read-what-changed)); step 7b now offers *parts* and adopts them with `add-part` ([upgrade](generators/upgrade.md#7b-offer-the-parts)). `offrails-gen vendor --framework "$FW"` re-vendors on any OS.
  - **CI:** `tools/ci/test-template.sh` generates the bare app, the app with every part (every sample installed into a fresh template app), and a bare app with each part added afterwards and then added again (which must change nothing), then runs fmt, clippy (with the no-`unwrap`/`expect` gate), the suite on SQLite and Postgres, `offrails-check` and the memory budget. The framework workflow runs it (`template`), plus `template-os`: Linux, macOS and Windows generate, build, lint and run the SQLite suite, bare and with every part. `test-install.sh` checks the release carries the template, the parts and `offrails-gen` without build output; shellcheck covers the new scripts. `tools/sync-parts.sh` refreshes `template/parts/` from a samples commit (recorded in `template/parts/SOURCE`).
  - **Changed docs:** [new](generators/new.md) (generate, then fill the header, parts, verify; a reference of what the template contains, including the anchors table), [interview](interview.md#then-confirm-the-plan-once-before-generating) (parts in the plan), [samples](guides/samples.md), [upgrade](generators/upgrade.md), [workflow](workflow.md), [doctrine](doctrine.md), [database](guides/database.md#kerneldbrs-spec) (points at the template's `db.rs` instead of a copy), [config](guides/config.md), README, AGENTS.md (prime directive 8), CHEATSHEET, cli, CONTRIBUTING, `blueprints/AGENTS.md`, `/offrails:new` and `/offrails:feature`.
  - **Not yet:** a `scaffold` template (resources are still built from [scaffold](generators/scaffold.md)); full templates for `inertia`, `spa` and `api` (`new --mode` generates the htmx template and logs the conversion, per [template → other modes](template/README.md#other-modes)); the guides other than `database.md` still carry long excerpts of files the template ships (the template wins where they differ).

  **App migration** (optional; no app needs to change):
  - Existing apps keep working as they are. After re-vendoring, `.offrails/template/` holds this release's template and parts.
  - *Optional, compare:* `diff -ru -x target -x .git -x .offrails -x storage .offrails/template/app .` shows where the app's kernel differs from the tested template; port what you want as ordinary, tested changes (never copy a template file over an app file).
  - *Optional, the alias:* copy the `[alias]` from `.offrails/template/app/.cargo/config.toml` into the app's `.cargo/config.toml` (and `.gitattributes` beside it), then `cargo offrails list-parts`. An app generated from 0.7.0's guides may lack anchors or shapes the parts wire into; `add-part` names each and changes nothing ([samples → using one](guides/samples.md#using-one)).
  - *Optional, memory:* add `worker_threads`/`blocking_threads` to `Config` and build the runtime from them as the template's `main.rs` does; set the SQLite `cache_size` pragma and idle timeout in `kernel/db.rs`. Prove it with `sh .offrails/tools/ci/memory-budget.sh . <budget>` (Linux, macOS).

## 0.7.0 (2026-10-16)

### What's new

- **Security fix: development is reachable only from this machine.** `serve` binds `127.0.0.1` in development (`APP_BIND`) and answers only local and configured host names (`APP_ALLOWED_HOSTS`), so another device on the network or a DNS-rebinding page can no longer open `/dev` (where the mailbox shows sign-in links). A new `offrails-check [bind]` rule enforces it.
- **Signing in replaces the previous session:** the old session in that browser is destroyed and its sockets and streams are closed.
- **Emailed links are limited per address,** with a cap of 20 emails per address per day for login and reset links.
- **Unverified accounts can't be pre-hijacked:** `users.email_verified_at` stays empty until a link sent to the address is used, and that first use drops every session, API token and pending link set up before it.
- **Less leakage:** job errors are stored redacted, `Config`'s `Debug` prints only settings that can never hold a secret, and presence labels never come from an email address.
- **Samples:** optional, tested reference code for the mailer, jobs, sign-in, realtime and a dev dashboard in [rust-off-rails/samples](https://github.com/rust-off-rails/samples). Upgrading offers them per area.

### Breaking changes

- **Every app:** the development server must bind `APP_BIND` (default `127.0.0.1` in development) and check `Host`; `offrails-check` fails with `[bind]` until it does. A dev container, tunnel or LAN workflow now needs `APP_BIND=0.0.0.0` plus `APP_ALLOWED_HOSTS`.
- **Apps with accounts:** sign-in destroys the old session; reset and login links get per-address and daily limits; apps with signup (or both passwords and login links) add a nullable `email_verified_at` column, and a user's first reset after the upgrade also revokes their API tokens.
- **Apps with a manual theme toggle:** the cookie is renamed from `theme` to `offrails_theme` (read the old one for one release).
- **Every app** also ports the security fixes: job errors stored redacted, `Config`'s `Debug` as an allowlist, and (apps with channels) presence labels that don't come from the email.

### Upgrading

Do the first entry's **App migration** (the development bind and Host check) first, then each entry's own App migration below. The procedure, including step 7b that offers the samples, is in [upgrade](generators/upgrade.md).

### Full list of changes

- **Security fix (P0): development was reachable from the LAN and through DNS rebinding.** An independent review of generated apps found that `serve` bound `0.0.0.0` in every environment ([cli](cli.md) said so) and nothing checked `Host`. `/dev` has no login: the mailbox shows every magic link and reset link, introspection shows the app's state, and `/admin/jobs` is open in development. So anyone on the same Wi-Fi could open `http://<your-ip>:3000/dev/mailbox` and sign in as you. A page on `evil.example` that re-points its own name at `127.0.0.1` (DNS rebinding) reached it too: the browser sends `Host: evil.example` **same-origin**, so CORS doesn't apply, and Fetch Metadata says `same-origin`, so the `CsrfLayer` lets its POSTs through. Now, in development only ([security](guides/security.md#development-is-this-machine-only), new non-negotiable 13):
  - `serve` binds `config.bind`: `APP_BIND`, default **`127.0.0.1` in development** and `0.0.0.0` in production. A dev container sets `APP_BIND=0.0.0.0` ([config](guides/config.md#where-serve-listens-app_bind-app_allowed_hosts-and-the-unset-app_env-warning), [deployment](guides/deployment.md#docker-compose-the-whole-app-locally)).
  - `kernel/hosts.rs` (whole file in the guide, like Rails' `HostAuthorization`) answers only `localhost`, `127.0.0.1`, `[::1]`, `*.localhost`, `APP_URL`'s host and `APP_ALLOWED_HOSTS` (a leading `.` allows subdomains). Any other `Host` gets a 403 page that says how to allow it. It's mounted right inside the security middleware ([routing](guides/routing.md#middleware)).
  - An unset `APP_ENV` still means development, but `Config::boot_warnings` now logs that at every boot. On a server it's a missing setting that mounts `/dev`.
  - New `offrails-check` rule **`[bind]`**, with fixtures. It fails a literal `0.0.0.0` bind in `src/` (`[0, 0, 0, 0]`, `"0.0.0.0:…"`, `Ipv4Addr::UNSPECIFIED` outside `config.rs`), a `config.rs` that never reads `APP_BIND`, and an app with no Host check. A `0.0.0.0` that isn't the server's bind ends its line with `// not the server bind`.

  Also updated: [new](generators/new.md) (steps 4 and 7 and the verify step), [cli](cli.md), [dev-tools](guides/dev-tools.md#rules), the CHEATSHEET, the app `AGENTS.md` NEVER list, `/offrails:security`, and `TestApp` gains `get_with_headers`/`post_form_with_headers`. **Changed:** the macOS port-collision note in [cli](cli.md#running-the-app-from-an-agent). A `127.0.0.1:$PORT` bind succeeds while another process holds `*:$PORT`, because tokio sets `SO_REUSEADDR`; then `localhost` over IPv6 may reach the other app. Verified on macOS 2026-10-16, so `lsof` before starting still matters. The code is from an app generated from these docs, where the five new tests pass: `development_refuses_a_rebound_host_name`, `development_answers_local_and_configured_hosts`, `production_and_test_answer_any_host`, `development_binds_loopback_unless_told_otherwise` and `an_unset_app_env_warns_at_boot`, plus `hosts.rs`'s two unit tests. The running server listened on `127.0.0.1` only, refused its LAN IP, answered `Host: evil.test` with 403 and printed the boot warning.

  **App migration** (every app; security, do it first):
  - *Detect:* `sh .offrails/tools/offrails-check` prints `[bind]`, or `grep -rnE '\[0, *0, *0, *0\]|"0\.0\.0\.0' src` matches, or `grep -c APP_BIND src/config.rs` is 0.
  - *Converge:*
    1. Add `bind`, `allowed_hosts` and `env_defaulted` to `Config`, with `bind_address`, `parse_allowed_hosts`, `boot_warnings` and their two tests, copied from [config](guides/config.md#where-serve-listens-app_bind-app_allowed_hosts-and-the-unset-app_env-warning). Add the `APP_BIND` and `APP_ALLOWED_HOSTS` lines to `.env.example`.
    2. In `main.rs`, log `boot_warnings()` after `init_tracing`. Every listener binds `SocketAddr::new(config.bind, port)`: the main port, `APP_DEV_HTTP_PORT`, and `kernel::server::run` if the app has one.
    3. Copy `src/kernel/hosts.rs` whole from [security](guides/security.md#development-is-this-machine-only), add `pub mod hosts;`, and mount it just inside `security::middleware` in `routes.rs`.
    4. Add the three request tests to `security_test.rs`, and `get_with_headers`/`post_form_with_headers` to `TestApp` if they're missing.
    5. Anything that runs development in a container (a dev container, a compose override with `APP_ENV: development`) sets `APP_BIND: 0.0.0.0`, publishes on `127.0.0.1:` only, and lists any non-localhost name in `APP_ALLOWED_HOSTS`. Production compose and the Dockerfile need no change: they set `APP_ENV=production`, which binds `0.0.0.0`.
    6. A `docs/decisions.md` line, a tunnel or a LAN workflow that relied on `0.0.0.0` becomes `APP_BIND=0.0.0.0` + `APP_ALLOWED_HOSTS=<name>` in `.env`. Say so to the human: that network can then read the mailbox.
  - *Prove it:* the suite on both databases, then with the app running in development: `lsof -nP -iTCP:$PORT -sTCP:LISTEN` shows `127.0.0.1:$PORT` (not `*:$PORT`); `curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.test' $URL/dev` prints `403`; `curl -m 3 http://$(ipconfig getifaddr en0 || hostname -I | cut -d' ' -f1):$PORT/up` is refused; starting with `APP_ENV` unset logs `APP_ENV is not set`; `sh .offrails/tools/offrails-check` shows `ok [bind]`.
- **Security: signing in over a session now ends that session and closes its connections** ([auth](guides/auth.md#sessions), item 5). This came from the same review, applied to the samples' auth. On a shared computer, Ann signs in (password, magic link or reset) while Bob's session is still in the browser. The guide said "replace that session", and an app that only overwrote the cookie left Bob's session row alive, along with any copy of its token and his open sockets and streams, until the next revalidation. Now the sign-in transaction calls `session::destroy` on the old session, which writes the session-ended signal. **App migration** (apps with accounts): *Detect:* the sign-in handlers (`sessions::create`, `login_links::consume`, the reset's `update`) don't take `Option<CurrentSession>`, or set the new cookie without destroying the old session. *Converge:* destroy it in the sign-in transaction, before `session::start`. *Prove it:* a test signs in as A, opens a stream or socket as A, then signs in as B in the same `TestApp`: A's connection gets `bye` `revoked`, and A's old cookie, set back with `set_cookie`, is logged out.
- **Security: emailed links are limited per address, with a daily cap** ([auth](guides/auth.md#flow)). Password reset requests were limited per IP only, so a flood from many IPs could fill one inbox without limit. Login links allowed 10 per 3 minutes per address, which is still 4,800 emails a day. Reset requests now get the per-address limit too (10 per 3 minutes), and both share a cap of **20 emails per address per day** (`emails/day:{email}`, a one-day window on the existing rate limiter). Over the cap it sends nothing and answers 429 like the other limits, which says nothing about whether the address exists. **App migration** (apps with accounts): *Detect:* `grep -rn 'emails/day' src` finds nothing. *Converge:* add the per-address key to `passwords::create` and the daily key to both handlers. *Prove it:* `link_requests_are_capped_per_address_per_day`: 21 requests for one address from different IPs (`APP_TRUSTED_PROXY=true`, varied `X-Forwarded-For`); the 21st sends nothing (`drain_jobs`, then `sent_emails`) and gets the 429 form, for a registered and an unregistered address alike.
- **Security: claiming an unverified account (pre-hijacking)** ([auth](guides/auth.md#claiming-an-unverified-account-pre-hijacking)). In `signup`, anyone could register a victim's address with a password they know and set things up on the account, such as an API token. When the owner later got in by a reset link, the reset ended the sessions but not the token, so the attacker kept access. The same goes for an app that offers both a password and a magic link. Now `users.email_verified_at` stays `NULL` until something sent to the address is used. The first proof (consuming a reset or a login link, accepting an invite) runs `user::claim_unverified` in its transaction, and when that claims the account it drops every session, API token, pending link and mail redirect set up before. A link sign-in also clears the password. Magic-link-only and invite-only apps have nothing to claim. The rule comes from the samples' `auth-core` (`claim_unverified_account`), where cross-method tests prove it; it is new here as a spec. **App migration** (`ACCOUNTS: signup`, or an app with both passwords and login links): *Detect:* `grep -rn email_verified_at migration/src src/models/user.rs` finds nothing. *Converge:* a migration adds the nullable column, backfilled `NULL`: never mark existing accounts verified, because that would bless an attacker's pre-registered one. A real user's first reset after the upgrade then also revokes their API tokens; tell the human. Add `claim_unverified`, call it in `password_reset::consume` and `login_link::consume`, and drop the pre-claim credentials on `true`. Magic-link first logins create users verified. *Prove it:* `an_api_token_made_before_the_first_reset_is_revoked` (apps with an API) or `a_password_set_before_verification_dies_when_the_owner_signs_in_by_link` (both methods), plus a second reset that keeps the tokens, on both databases.
- **Security: job errors are stored redacted** ([jobs](guides/jobs.md), [errors-and-logging](guides/errors-and-logging.md#redacting-stored-error-text)). From the samples' review: a failure message often quotes the URL that failed, credentials and tokens included (`https://deploy:hunter2@api…?token=…`). The executor stored it in `jobs.errors`, which `/admin/jobs` shows and searches and `app jobs show` prints. Errors now go through the new `kernel::redact::text` before they're stored or logged. It redacts URL userinfo, random-looking path segments, secret-named query values and `key=value` pairs, and long random words. The whole file is in the guide, trimmed from the samples' host and tested on its own. **App migration:** *Detect:* `grep -rn 'redact::text' src/kernel/jobs` finds nothing. *Converge:* add `src/kernel/redact.rs` from the guide, and call `redact::text` on the message in the executor's error push and its `job.exception` log; existing rows stay as they are (offer the human a one-off task that rewrites `errors` through it, or let the Pruner age them out). *Prove it:* `job_errors_are_stored_redacted` on both databases. **Not ported:** the samples' search over redacted args. The framework's dashboard shows args unredacted, because secrets never go in args (rule 7), so searching raw args reveals nothing the page doesn't.
- **Security: `Config`'s `Debug` is an allowlist** ([config](guides/config.md#srcconfigrs)). From the samples' review of their dev dashboard's config page. The guide said to print `[redacted]` for three named fields, a denylist. The next secret someone adds (a Stripe key, a webhook signing secret) then went out in full on `/dev/introspect.json` and `app introspect`. Now `Debug` prints values only for settings that can never hold a secret, and `set`/`not set` for the rest. **App migration:** *Detect:* `src/config.rs`'s `impl fmt::Debug for Config` prints a `String` or `Option<String>` field outside the allowlist with `&self.<field>`. *Converge:* print `set`/`not set` for those. *Prove it:* `debug_shows_no_secret_values` (a sentinel in every non-allowlisted string field, none in `format!("{c:?}")`), then `curl -s $URL/dev/introspect.json | grep -c sentinel` is 0 with a sentinel in `.env`.
- **Presence never derives a label from the email** ([channels](guides/channels.md#security)). Also from the samples' review. The guide's `member_for` built presence initials from `u.email`, and `ann.lee@…` → "AL" tells a room who's there. Presence now uses a display name the person chose (or its initials), else a neutral `Member <n>`. **App migration** (apps with channels): *Detect:* `grep -rn 'initials(&u.email\|initials(&user.email' src`. *Converge:* derive it from the user's own name field, or `Member <id>` when there is none. *Prove it:* the presence test asserts no `@` and, for a user without a name, `Member`.
- **Samples: optional, tested reference code** ([samples](guides/samples.md)). The parts most apps need now exist as code that was built, tested on SQLite and Postgres, given a STRIDE threat model and reviewed adversarially, in a separate public repo, [rust-off-rails/samples](https://github.com/rust-off-rails/samples), tagged per framework release: `mailer`, `dev-mailbox`, `jobs`, `jobs-dashboard`, `auth-core`, `auth-password`, `auth-magic-link`, `auth-totp`, `realtime` and `dev-dashboard`. Each has a README (*Adapting it*, *Do not change*), a CHECKS.md whose every row cites a test, a PROMPT.md and a `sample.toml`; `tools/install.sh` there installs one by its `// samples: <anchor>` comments. **The framework stays Markdown and the guides stay the spec.** A sample is never required and never vendored automatically, and an agent offers it and never imposes it. New: [guides/samples.md](guides/samples.md) (rules, how to adopt one, the anchors, what each sample adds); a *Reference implementation (optional)* note in [mailers](guides/mailers.md), [jobs](guides/jobs.md), [jobs-dashboard](guides/jobs-dashboard.md), [auth](guides/auth.md), [channels](guides/channels.md), [realtime](guides/realtime.md), [dev-tools](guides/dev-tools.md) and [errors-and-logging](guides/errors-and-logging.md); a **head start** question with the plan in the [interview](interview.md#then-confirm-the-plan-once-before-generating) (default: build from the guides), and in [new](generators/new.md), the head-start path that takes the mailer, mailbox, jobs and dashboard from the samples (same profile, same rules). **Sample anchors in new apps** ([new](generators/new.md#anchors), step 13): one-line `samples: <anchor>` comments in the shared files, each on a line of its own. rustfmt (1.99, checked) leaves a standalone comment in place, but carries a trailing one with its line: `pub mod z; // samples: kernel mods` moves when the `mod` lines are sorted. So anchors never trail code, and the step ends with a grep that proves it. The [logging](guides/errors-and-logging.md#logging) init is now a registry with the `samples: tracing layers` list, added through `sample_layers()` as an `Option`: an empty `Vec` layer silences every log line (found in the samples' host; `an_app_without_sample_layers_still_logs`, compiled with tracing-subscriber 0.3.23). One caveat, stated in the guide: samples are proven in the samples repo's `host/`, which is `new` without the mailer, mailbox and jobs kernel, plus a few pieces they build on (the theme switch, `kernel/cookie.rs`, `kernel/redact.rs`, `Chrome::stylesheet`), so adopting one may mean porting those first. **App migration:** nothing is required. *Detect:* every app. *Converge:* run [upgrade step 7b](generators/upgrade.md#7b-offer-the-parts) (next entry). Add the anchors from new step 13 only if the human adopts a sample and you install it with `install.sh`. An app that keeps its own `init_tracing` keeps it; switch to the registry form only together with a sample that adds a layer.
- **Upgrading to 0.7.0 offers the samples** ([upgrade](generators/upgrade.md#7b-offer-the-parts), step 7b). This is optional, and the human decides each area. **App migration** (every app):
  - *Detect:* the areas the app has: mailer (`ls src/kernel/mailer.rs src/mailers/mod.rs`), dev mailbox (`grep -rln '"/dev/mailbox' src`), jobs (`ls -d src/kernel/jobs`), jobs dashboard (`grep -rln '"/admin/jobs' src`), accounts (the `ACCOUNTS:` header line; `password_digest`, `src/models/login_link.rs`, `totp`), realtime (`ls -d src/channels src/kernel/channels.rs`), and a dev dashboard (`grep -rn '"/dev/dashboard' src`; usually none).
  - *Converge:* for each area found, read the sample's README and compare it with what the app has. Then, in the same message as the upgrade plan, tell the human in a sentence or two per area what the sample would add **to this app**, and what adopting costs. A mailbox with HTML, text and source views and a 320 px phone lint; jobs on the database with a dashboard that groups errors by root cause; accounts with TOTP, recovery codes, magic link + code and an account security page; channels with presence and authorization on every join; a LiveDashboard-style dev dashboard with traces, SQL with N+1 detection, a log tail and a routes explorer. Be enthusiastic where it's genuinely better and plain about the cost (files replaced, tables to reconcile, more code to own). Leave out what the app already does as well. Ask per area: **adopt**, **borrow ideas** or **skip**. Never replace working code without that yes, and in a non-interactive run install nothing: put the offer in the report.
  - *If adopting:* one slice per sample, after the convergence slices. Use the samples tag that matches `.offrails/VERSION`, install its dependencies first, and never copy a migration for a table the app already has (reconcile it). Port the sample's tests and its CHECKS.md rows into the feature spec's `## Security` table, keep its `// SECURITY:` markers, and delete the replaced code only once the sample's and the app's tests are green. Then the Definition of Done on SQLite and Postgres.
  - *Prove it:* `docs/decisions.md` has `YYYY-MM-DD: samples offered on upgrade to 0.7.0: <sample> adopted|borrowed|skipped, …`. For each adopted sample, its tests run green in the app's suite on both databases, and `sh .offrails/tools/offrails-check` passes with its Security rows.
- **Route sweeps skip reading SSE bodies** ([test-standards §3](guides/test-standards.md#3-security-tests-every-app), [new](generators/new.md#tests)). Agents reported that the "every `ROUTES` entry is routable" sweep and the access matrix hang forever on streaming routes (`/streams/{stream}`, `/socket/sse`, a mailbox `events` stream): `TestApp::get` waits for a body that never ends. Streaming routes now go through `TestApp::request_head`, which is in the `TestApp` spec now with the reference signature `(path, headers) -> (StatusCode, HeaderMap)`, chosen by a `STREAMING` list next to the test, plus a check that every `Sse<…>` handler is on that list. **App migration** (apps with SSE): *Detect:* `cargo nextest run security_test` hangs, or `grep -rn 'Sse<' src` finds handlers that aren't in a `STREAMING` list in `tests/app/security_test.rs`. *Converge:* add the list and use `request_head` for those routes. *Prove it:* the security suite finishes on both databases.
- **The theme cookie is `offrails_theme`** ([views](guides/views.md#dark-mode)). The manual-toggle recipe said `theme` (`light` | `dark`, absent = system), and the samples' kernel pages use `offrails_theme=system|light|dark`, so an app and its kernel pages could disagree. Now there's one cookie and one switch: `offrails_theme`, values `system|light|dark` (anything else means system), not `HttpOnly` (the script writes it), exempted by exact name from the production `HttpOnly` backstop, with the server rendering `data-theme` and `color-scheme`, and `POST /theme` without JS taking only a same-site `return_to`. **App migration** (apps with a manual theme toggle): *Detect:* `grep -rn '"theme"' src static/app.js` finds the cookie name. *Converge:* rename it to `offrails_theme`. For one release, also read the old `theme` cookie when the new one is absent, so nobody's choice resets. Add the exemption and its production test. *Prove it:* the production cookie test, then `run.sh a11y` in both schemes with the switch forcing each.
- **Doc gaps from agents, checked:** (1) *`new` builds the mailer and jobs into every app.* That's deliberate ("one profile", [new](generators/new.md)) and stays, because the second feature of most apps needs one of them. What's new is the head start, which takes that kernel from the samples instead of from prose. (2) *The mailers guide assumes jobs.* True, and correct for every app `new` makes. [mailers](guides/mailers.md) now says so, and says what to do without a queue (the `mailer` sample alone). (3) *rustfmt moves anchor comments.* Only trailing ones, and only with their line; anchors stand alone now ([samples](guides/samples.md#anchors)). (4) *Node 22.12 vs 20.19.* `20.19` is Vite 8's own `engines` (`^20.19.0 || >=22.12.0`, checked in 8.2.2), not the stack's floor. [stack](stack.md#blessed-js-packages-inertia-spa-islands) and [new](generators/new.md#0-preconditions) now say that TS apps need ≥ 22.22, the smokes ≥ 22.12, and Node 24 LTS for both. **App migration:** none.

## 0.6.2 (2026-10-13)

### What's new

- **`offrails-check` catches 32-bit ids** (`[ids]`), which pass on SQLite and break every Postgres write, and test suites that never run on Postgres (`[postgres]`).
- **`bin/dev` manages its own process:** it writes a PID file per server, refuses to start twice on one port, and on exit stops only its own process tree. Agents stop a server with `kill "$(cat storage/pids/…)"`.
- **Seeds can be re-run** after a half-failed run: one transaction, each record found or created by a natural key.
- **WebSockets sized for many sockets:** smaller per-socket buffers took 10,000 sockets from 1.37 GB to 160–225 MB.
- **Whole files in the guides** for `kernel/tls.rs` and `kernel/introspect.rs`, and a pattern for live boards over a channel (snapshot, patches, reducer).
- **Parallel helpers only after the skeleton compiles,** each with its own files.

### Breaking changes

- `offrails-check` fails on the new `[ids]` and `[postgres]` rules: an app with 32-bit ids adds a widening migration (never edits the applied ones), and `tests/app/support.rs` must read `TEST_DATABASE_URL`.
- `bin/dev` no longer uses `trap 'kill 0'` (it could kill the caller's shell); copy the new top of the script.

### Upgrading

Each entry's **App migration** note below, then [upgrade](generators/upgrade.md). This release also corrects the 0.6.1 steps in place.

### Full list of changes

- **Parallel helpers only after the skeleton compiles** ([workflow](workflow.md#parallel-helpers-subagents)). A real migration (Fizzy → Rust off Rails, run in opencode) had three helpers writing the jobs executor, the jobs dashboard and the dev mailbox into one uncompiled crate at once. Each tripped over the others' half-written files, and two built stub crates in `/tmp` just to type-check their own part. Now: the coordinator builds the skeleton alone, up to the first green `cargo check` and the baseline commit. After that it runs at most one helper per vertical slice, with each helper's files listed. Shared registries (`routes.rs`, `mod.rs` files, `migration/src/lib.rs`, `Cargo.toml`) stay with the coordinator, and a helper blocked by another's file stops and reports. **App migration:** docs only. *Detect:* the app's `AGENTS.md` or any decision line telling agents to fan out helpers on code before the tree compiles. *Converge:* drop it and follow the workflow rule.
- **Fixed (P0): 32-bit ids passed on SQLite and broke every Postgres write.** A peer's Kanban (0.3.0 → 0.6.1) used `.integer()` for ids and foreign keys. SQLite's `INTEGER` is 64-bit, so the suite was green; on Postgres the columns were `INT4` and every write that returned an id was a 500. Its tests had never run on Postgres: `support.rs` hard-coded `sqlite::memory:`. Two new `offrails-check` rules, with fixtures: **`[ids]`** fails an `id`/`*_id` column defined with `integer`/`small_integer`/`tiny_integer` (helper or `ColumnDef` chain, across lines, `Alias::new("x_id")` too) or `pk_auto`, and an `i32`/`i16` id field in `src/`; **`[postgres]`** fails a `tests/app/support.rs` that never reads `TEST_DATABASE_URL`. A migration repaired by a later one carries `// offrails-check: ids widened in m<…>` ([migrations](guides/migrations.md#ids-are-64-bit), with the `widen_ids` template, its SQL verified on Postgres 18). **App migration:** *Detect:* `sh .offrails/tools/offrails-check` prints `[ids]` or `[postgres]`. *Converge:* `[postgres]`: `TestApp::new` per [testing](guides/testing.md#database-per-test) first, then run the suite on Postgres and fix what it finds. `[ids]`: never edit the applied migrations; add `widen_ids` (Postgres only; `ALTER SEQUENCE … AS bigint` for `pk_auto` tables), put the waiver comment in each repaired file, change entity fields to `i64`, then `db rollback --steps 1 && db migrate` and the suite on both databases.
- **Seeds retry after a half-failed run.** The same app's `seed_if_empty` returned early when any team existed, so a seed that failed after the first team never ran again. `tasks/seed.rs` now runs in one `kernel::db::begin` transaction and finds-or-creates each record by a natural key ([cli](cli.md#seeds); scaffold and new updated). **App migration:** *Detect:* `grep -nE 'count|is_empty|exists' src/tasks/seed.rs` gating the whole seed with an early return. *Converge:* one transaction, find-or-create per record; prove it by running `app db seed` twice (no change) and after deleting one seeded row (it comes back).
- **One PID file per server; cli.md and the NEVER list now agree on how to stop one.** cli.md said to stop `bin/dev` by its own process; the NEVER list and the hook said `lsof … | xargs kill` on the listener, which leaves cargo watch and Tailwind running. A peer's agent (dev-92, opencode) also broke the name rule with `pkill -f` and killed the human's demo: the hook that blocks `pkill` exists only in Claude Code. Now `bin/dev` writes `storage/pids/dev-<port>.pid`, refuses to start when that PID is alive or the port is taken, and on exit stops **only its own process tree** (leaves first) and removes the file. A plain `serve` is started as `sh -c 'echo $$ > storage/pids/serve-<port>.pid; exec cargo run -q -- serve'` (`cargo run` execs the app, so the PID is the server's; verified). Stop either with `kill "$(cat storage/pids/…)"` ([cli](cli.md#running-the-app-from-an-agent); the NEVER line, CHEATSHEET 14 and the hook message match). **Also fixed:** 0.6.1's `trap 'kill 0' EXIT` signals the whole process group, and a `bin/dev` started from an agent's shell isn't a group leader, so stopping it also killed the caller's shell (reproduced with a mock tree). **App migration:** *Detect:* `grep -n "kill 0" bin/dev`, or `grep -c 'storage/pids' bin/dev` is 0. *Converge:* copy the top of `bin/dev` from [dev-tools](guides/dev-tools.md#the-bindev-script) (PID file, port check, `cleanup` trap) and the app's `AGENTS.md` NEVER line from [`blueprints/AGENTS.md`](blueprints/AGENTS.md). Prove it from a non-interactive shell: start `bin/dev` in the background, `kill "$(cat storage/pids/dev-$PORT.pid)"`, then `lsof -nP -iTCP:$PORT -sTCP:LISTEN` prints nothing, no `cargo watch`/`tailwindcss` of this app is left, and the shell that ran it is still alive.
- **Fixed: `bin/dev`'s Tailwind watcher overwrote the minified CSS with an unminified build.** The watcher now runs with `--minify`, like the production build (verified with Tailwind 4.3.3: rebuilds after a template edit stay minified) ([dev-tools](guides/dev-tools.md#hot-reload)). **App migration:** *Detect:* `grep -n 'tailwindcss.*--watch' bin/dev | grep -v -- --minify`. *Converge:* add `--minify` to that line.
- **`kernel/tls.rs` and `kernel/introspect.rs` are whole files in the guides**, not sketches pointing at "the reference app's file", which an upgrading agent can't see. [deployment](guides/deployment.md#direct-tls-optional) has the full `HttpToHttps` acceptor with its four tests; [dev-tools](guides/dev-tools.md#introspection) has the full `introspect.rs` (with `// APP:` marking the realtime part), the two handlers, the `app introspect` arm and `kernel::css::css_warning()` for the banner. Both come from an app that implemented them from the 0.6.1 spec (axum-server 0.8.0, sea-orm 2.0.4), generalized. `PubSub` gains `streams()` for the introspection's realtime part. **App migration:** *Detect:* an app that wrote either file from the 0.6.1 sketch. *Converge:* diff it against the guide's file; adopt the guide's tests if yours has fewer. Nothing to do if they match.
- **Upgrade notes that fit apps without every feature** (from the website's 0.6.0 → 0.6.1 upgrade: no accounts, no live pages, no pending-migrations page, no CSS banner). The 0.6.1 App migration steps below are corrected in place: step 11 is split into **11** (every app: 503 + `Retry-After`, the pool's `acquire_timeout`, `AppError::Unavailable`) and **11b** (only apps with live regions: `X-Live-Refresh` and the gate), since its old detect matched every app; step 5 also detects an app with **no** CSS banner and says how to add one; step 6 exempts the dev routes from the pending-migrations guard only if the app has one; step 2's proof for apps without accounts is `run.sh a11y`. [upgrade](generators/upgrade.md) gains the rule behind these (a step applies only where its *Detect* matches; otherwise it's n/a, with the reason, in the plan) and a preconditions paragraph. **App migration:** none; upgrades from 0.6.0 follow the corrected steps.
- **The browser smokes need Node ≥ 22.12**, and now say so everywhere they're a proof ([upgrade](generators/upgrade.md#2-start-clean), the 0.6.1 steps, [dev-tools](guides/dev-tools.md#browser-smoke)). **Fixed:** `run.sh` only checked the major version, so Node 22.0–22.11 passed the gate and failed inside puppeteer; it now compares major and minor. **App migration:** none (`.offrails/` is re-vendored).
- **The stale-CSS guard says to delete dead classes.** It lists template classes with no CSS rule, and the guide only offered the `HOOK_CLASSES` allowlist, so dead classes got allowlisted. Now, per class: delete it if nothing uses it, rebuild the CSS for a real utility, allowlist only JS/test hooks with a reason ([test-standards §11](guides/test-standards.md#11-theme-and-css-guards); the template's failure message says the same). **App migration:** *Detect:* each `HOOK_CLASSES` entry. *Converge:* `grep -rn '<class>' static/*.js web/src tests` finds no use → remove the entry and the class from the template; update `theme_test.rs`'s failure message.
- **WebSocket buffers sized for many sockets** (from a peer's 10,000-socket run on 0.6.1). `kernel::channels::upgrade` sets `.read_buffer_size(4 * 1024).write_buffer_size(0).max_write_buffer_size(256 * 1024)` (plus message and frame size = `max_frame`); axum's defaults are 128 KiB each way per socket. Measured at 10,000 sockets: 1.37 GB → 160–225 MB, failed handshakes 17 → 0, fan-out p50 320–590 → 58–88 ms ([channels](guides/channels.md#the-connection-kernelchannelsrs); the methods checked against axum 0.8.9). **App migration** (apps with channels): *Detect:* `grep -n 'read_buffer_size' src/kernel/channels.rs` finds nothing. *Converge:* add the five calls before `on_upgrade`; the wire tests must stay green (an oversized frame still closes the socket).
- **Fixed: forwarders replayed history after a restart.** A row forwarder with a look-back window (outboxes, board events, chat rows written over HTTP) deduped with an in-memory set that starts empty, so its first poll after boot re-sent the whole window. Seed the set from the window at start ([channels](guides/channels.md#feeding-a-topic-from-rows-forwarders), with the test: 3 events of history, a restart, a 4th event → exactly the 4th is delivered). The pub/sub poller was never affected (it seeks to the end). **App migration:** *Detect:* a task that reads rows newer than `now - <window>` (or the last K ids) and keeps a `HashSet` of sent ids, built empty. *Converge:* fill the set from the window in its constructor, before the first poll; add the restart test.
- **Live boards over a channel: snapshot, patches, reducer** ([channels](guides/channels.md#live-boards-over-a-channel-snapshot-patches-reducer)). The pattern a peer measured at 10,000 watchers (p50 58–88 ms, zero re-fetches): the join's `ok.data` is the snapshot and its `seq` without `since`, or `seq` + replayed patches with it; patches carry the final list order and go through one pure, idempotent reducer; actions stay HTTP POSTs, optimistic and confirmed by the channel; a JSON snapshot is polled while the socket is down. **App migration:** none (a pattern for new live boards); an existing board that re-fetches HTML per signal keeps working.
- **Load testing: the per-user cap explains "closed" counts.** At 128 connections per user, N connections need ⌈N/128⌉ users; the `tools/loadgen` spec now says so and requires the generator to print the cap as the reason for refused or closed connections ([realtime](guides/realtime.md#load-testing-toolsloadgen-spec), [channels](guides/channels.md#measured-reference-app-m-series-laptop-under-heavy-parallel-load-2026-10-12)). **App migration:** none.
- **README: "~50 Markdown files"**, not ~30 (27 guides, 15 root docs, 8 generators, 4 blueprints). **App migration:** none.

## 0.6.1 (2026-10-13)

### What's new

- **Sign-in over dev HTTPS works again:** development serves HTTPS only with a locally trusted (mkcert) certificate, and otherwise plain HTTP with a message saying why. `APP_DEV_HTTP_PORT` serves plain HTTP beside TLS.
- **Dark mode that stays readable:** a palette, layout skeletons and components checked with axe in both color schemes, plus guard tests for unpaired colors and for stale compiled CSS (also shown as a development banner).
- **Hot reload and introspection:** open tabs reload after an edit, and `/dev/introspect.json` and `app introspect` tell an agent the app's routes, config, migrations, jobs and realtime state.
- **The `offrails` command is safe to script:** every subcommand answers `--help` without side effects, unknown flags exit 2, and `new` and `upgrade` never start an agent from a non-interactive shell without `--yes`.
- **Realtime at scale** (5,000 watchers on one box): pub/sub per stream, live regions rendered once per version, a gate so re-fetches can't starve user requests, and overload answered with 503 and `Retry-After`.
- **Rate limits never extend a lockout,** and `offrails-check` gains an `[env]` rule for variables missing from `.env.example`.

### Breaking changes

- `offrails new` and `offrails upgrade` in scripts or CI exit 3 without `--yes` (or `OFFRAILS_YES=1`).
- `bin/dev-cert` no longer falls back to a self-signed certificate; without mkcert, development serves plain HTTP (or set `DEV_CERT_SELF_SIGNED=1`). Delete old certificates in `storage/dev-certs`.
- A request timeout or an exhausted database pool answers 503 with `Retry-After`, not 408.
- `offrails-check` fails on a missing `.env.example` variable (`[env]`) and on a bare `n/a` STRIDE row.
- New settings `APP_SHUTDOWN_DRAIN_SECS` and `APP_DEV_HTTP_PORT`; development allows 1000 attempts per rate-limit window.

### Upgrading

The numbered **App migration** list (steps 1–16) at the end of this section, for 0.6.0 apps; older apps apply 0.6.0's steps first. The procedure is in [upgrade](generators/upgrade.md).

### Full list of changes

Fixes from a peer's Kanban on 0.6.0 (and its 5,000-watcher load test), from the website's upgrade to 0.6.0, and two agent-experience requests. Every app-facing fix is proven in the reference app (bookshelf) unless marked otherwise.

- **Fixed (P0): login silently failed over dev HTTPS.** 0.6.0's `bin/dev-cert` fell back to an openssl **self-signed** certificate when mkcert was missing. After "Proceed to localhost (unsafe)", Chrome drops `Set-Cookie`: the magic link returned 303, the account was created, and the user bounced back to login. Our smoke missed it because it ran Chrome with `--ignore-certificate-errors`. Now **dev serves HTTPS only with a locally trusted certificate**: `bin/dev-cert` uses mkcert and refuses (exit 3) when mkcert is missing or its CA isn't installed, unless `DEV_CERT_SELF_SIGNED=1`; `bin/dev` runs it on every start and otherwise serves plain HTTP/1.1, saying why, how to install mkcert, and that HTTP/1.1 limits live pages to ~6 tabs. It prints the exact URL with its scheme, again as `✓ ready: …` once the port answers ([dev-tools](guides/dev-tools.md#https-in-development)).
- **Dev HTTP beside TLS** (from a peer, verified in a stock Chrome profile and here): `APP_DEV_HTTP_PORT` (development only) serves the same router and `AppState` on plain HTTP next to the TLS port. `bin/dev` sets it with `DEV_CERT_SELF_SIGNED=1` and tells the user to open the http URL; scripts keep h2 on the TLS port.
- **A bare `localhost:PORT` on the TLS port** no longer shows `ERR_INVALID_HTTP_RESPONSE`: `kernel/tls.rs` sniffs the first byte and answers plain HTTP with a 307 to `https://` ([deployment](guides/deployment.md#direct-tls-optional)).
- **Fixed: `new.md` step 4.12 bound the port twice** (`bind_rustls(addr)` after binding a listener). Bind once; the TLS branch uses `axum_server::from_tcp(listener.into_std()?)` with the sniffing acceptor (verified with axum-server 0.8.0).
- **Browser smokes never ignore certificate errors.** Every smoke in `tools/browser-smoke/` launches Chrome with no certificate overrides and fails at a preflight on an untrusted certificate. New smokes: `run.sh login` (logs in through the real form, then proves a fresh page load is still logged in) and `run.sh a11y` (axe-core 4.13.0, WCAG 2.2 AA, every path in light **and** dark). Verified: against a forced self-signed certificate `login` and `live-tabs` fail; over the http fallback and a trusted certificate both pass, and `live-tabs` reaches 12/12 tabs over h2. The trusted-certificate run emulated trust with Chrome's SPKI pin, since installing a CA on the verification machine wasn't authorized ([dev-tools](guides/dev-tools.md#browser-smoke), [test-standards §9](guides/test-standards.md#9-real-browser-smoke)).
- **Fixed: dark mode shipped unreadable** (`bg-white` with no `dark:` partner under a body that turned dark: 1.09:1). [views](guides/views.md#dark-mode) now has the palette (light/dark pairs per role), the skeletons (body, nav, flash, error summary, mailbox hint, lists, auth footers) and the component layer (`.label`, `.input`, `.btn*`, `.link`, which is now underlined because axe flags color-only links), all verified with axe in both schemes. The inertia and svelte snippets are fixed too. New guard-test template `tests/app/theme_test.rs` ([test-standards §11](guides/test-standards.md#11-theme-and-css-guards)): (a) a light surface needs a `dark:bg-*`; (b) dark text needs a `dark:text-*`; (c) faint tones need a `dark:text-*`. It reads the templates and `@apply` lists. In the reference app it listed 75 unpaired tones, and with one partner removed axe failed dark at 1.07:1 while light passed.
- **Fixed: stale compiled CSS failed silently.** A second guard in `theme_test.rs` checks that every template class has its escaped selector in `static/app.css` (skipped when unbuilt). The same check (`kernel/css.rs`) drives a development banner and `/dev/introspect.json`'s `css.stale`. In the reference app it listed the 67 new `dark:` classes until Tailwind ran. `bin/dev` keeps Tailwind in `--watch`.
- **Hot reload in development:** `cargo watch` rebuilds and restarts on Rust and template edits, Tailwind watches CSS, and `static/dev-reload.js` reloads open tabs when `GET /dev/reload` changes (boot id + newest mtime under `static/`). It's a 1 s poll, not a held stream, because a stream per tab would eat the HTTP/1.1 connection budget. Measured: a CSS edit reached an open tab in 0.6 s, a template edit in ~17 s. The skeleton `Cargo.toml` gets `[profile.dev] debug = "line-tables-only"` and `split-debuginfo = "unpacked"`: a peer measured template edits going from 16–23 s to 3.6–5.9 s; not reproduced in the smaller reference app (~5 s incremental builds either way). lldb loses locals; use `CARGO_PROFILE_DEV_DEBUG=true` ([dev-tools](guides/dev-tools.md#hot-reload)).
- **Introspection for agents:** `GET /dev/introspect.json` (development only, absent in production and test, tested) and `app introspect` report routes, redacted config, migrations, jobs, realtime (signal subscribers, connections per member and transport, topics), assets, the browser protocol and CSS staleness. The workflow loop, the Definition of Done, `cli.md` and the app's `AGENTS.md` tell agents to ask it before guessing ([dev-tools](guides/dev-tools.md#introspection)).
- **Fixed: `offrails upgrade --help` launched the upgrade agent** (0.5.0 ignored upgrade's arguments; in 0.6.0, `update --help` still ran the installer and `check --help` ran the checker). Every subcommand now answers `-h`/`--help` (and `offrails help <command>`) with its own help, exits 0 and does nothing else. Unknown commands and flags exit 2 with usage, and `update` validates installer flags before downloading. `new` and `upgrade` never start an agent from a non-interactive shell without `--yes` (or `OFFRAILS_YES=1`): they print the command they would run and exit 3. Exit codes: 0 ok, 1 error, 2 usage, 3 needs `--yes`, 10 upgrade due ([INSTALL](INSTALL.md#the-offrails-command)). `tools/ci/test-install.sh` runs `-h` and `--help` on every subcommand with fake agents on PATH and asserts none starts.
- **`--fw` on an old CLI:** an installed `offrails` older than 0.6.0 silently ignores `--fw` and compares against `~/.offrails/current`. [upgrade](generators/upgrade.md#1-is-there-anything-to-upgrade) now says to run the target release's own script (`sh "$FW/tools/offrails" upgrade --check --fw "$FW"`) or `offrails update` first.
- **Realtime at scale** (measured by a peer: 5,000 SSE watchers on one box, 154 MB, fan-out p50 ≈ 40 ms; the database and rendering were the bottleneck, not streams). Pub/sub keeps one sender **per stream** (`pubsub.subscribe(&stream)`, swept when its last receiver goes), so a signal wakes only that stream's watchers. Hub revocations are keyed per member the same way. Shutdown ends every stream at once (SSE with no `bye`, so browsers reconnect and resume; sockets `bye restart` 1012), then drains for at most `APP_SHUTDOWN_DRAIN_SECS` (5); on 0.6.0 it took 10–35 s with 5,000 streams. The drain is proven here: 2.1 s with a stuck connection and a 2 s bound ([realtime](guides/realtime.md#measured-capacity)).
- **Fixed: the thundering herd on live pages.** Every watcher re-fetched and re-rendered the same fragment after a signal. With 5,000 watchers on one board, one card move cost ~5,000 renders, page loads took 12–25 s and requests timed out. Live regions many people watch now render **once per stream version**. Pub/sub keeps a per-stream version, bumped in `poll_once` where the committed row is observed, before the signal is sent (never at write time). The fragment handler caches the HTML in moka keyed `(stream, version)` with `try_get_with` (single-flight), authorizes every request first, and filtered views bypass the cache. Measured after: GET 10–107 ms, move 10–264 ms, 0 pool-acquire warnings ([realtime](guides/realtime.md#render-once-per-version-the-thundering-herd)).
- **Live re-fetches can't starve user requests, and overload is 503, not 408.** Live regions send `X-Live-Refresh`, and `kernel::live::gate` caps those requests to a third of the pool. The pool's `acquire_timeout` is 5 s, mapped to `AppError::Unavailable`. The request timeout answers 503, and any 503 gets `Retry-After` ([realtime](guides/realtime.md#keep-re-fetches-from-starving-user-requests), [errors](guides/errors-and-logging.md#response-mapping)).
- **Presence over SSE**, for pages that already stream signals and only show a count. Each stream holds a join guard whose `Drop` is the leave; people and tabs are counted; joins and leaves only mark a room dirty; one task publishes one label per dirty room every 250 ms. A connecting tab gets the current label at once. Without coalescing, 5,000 arrivals woke 5,000 receivers 5,000 times. Per process; multi-node needs the pub/sub table ([channels](guides/channels.md#presence-over-sse-no-websocket)).
- **No full reload after a mutation:** move the DOM optimistically, then re-fetch the live region in place with `htmx.ajax` ([frontend-htmx](guides/frontend-htmx.md#no-full-reload-after-a-mutation)).
- **Load testing spec (`tools/loadgen`):** N watchers, M mutations, per-event delivery latency, a presence assertion, and server RSS/fd sampling, with client CPU reported separately. Lessons: pick the target explicitly, cache sessions between runs, about 5,000 TLS+h2 streams per Node process, and never ignore certificate errors on login checks ([realtime](guides/realtime.md#load-testing-toolsloadgen-spec)). Measured capacity is reported as reached, not guaranteed, with the open unknowns: 10–49 of 5,000 connections failed client-side on some restarts.
- **Rate limits never extend a lockout.** The window is fixed from the first counted attempt and refused attempts aren't counted, so retrying while locked out doesn't push the unlock back. **Development allows 1000 per window** (test and production unchanged), decided once in `RateLimiter::new(config.env)` and pinned by a test ([security](guides/security.md#rate-limiting-kernelrate_limitrs)).
- **`n/a` threats in the STRIDE table** are written `| <Category> | n/a: <why> | — | — |`. `offrails-check` accepts that form, counts the rows, and fails a bare `n/a`/`none`/`—` row or a named threat whose test is `—` ([feature-spec](blueprints/feature-spec.md)).
- **Fixed: the Definition of Done's `.env.example` check never found a missing variable** in configs that read through a helper (`parse("PORT", "3000")`); it only grepped `var("…")`. It's now `offrails-check`'s `[env]` rule: every `"SCREAMING_SNAKE"` literal in `src/config.rs` must be in `.env.example`, with fixture tests. In the reference app it sees 27 variables where the old grep saw 23.

**App migration** (0.6.0 apps; earlier apps first apply 0.6.0's notes; reconcile: *detect* → *final shape* → *converge*, one slice each, green between them):

1. **Run the check.** `sh "$FW/tools/offrails-check"` (new: `[env]`, the n/a rule). After re-vendoring it's `sh .offrails/tools/offrails-check`, and it must exit 0 at the end. Upgrading with an installed `offrails` older than 0.6.0: *detect* `offrails version` below 0.6.0; *converge* `offrails update`, or run `sh "$FW/tools/offrails" upgrade --check --fw "$FW"`.
2. **Dev TLS: trusted or nothing.** *Detect:* `grep -n 'openssl req' bin/dev-cert` finds the unconditional fallback, or `grep -n 'DEV_CERT_SELF_SIGNED' bin/dev-cert` finds nothing, or `bin/dev` tests `[ -f storage/dev-certs/cert.pem ] || bin/dev-cert`. *Final shape:* `bin/dev-cert` from [realtime](guides/realtime.md#many-tabs-stall-http11-vs-http2) and `bin/dev` from [dev-tools](guides/dev-tools.md#the-bindev-script). *Converge:* copy both, then `rm -rf storage/dev-certs` so a 0.6.0 self-signed pair can't be reused. Prove it: without mkcert, `bin/dev` prints the http fallback message; `SCENARIO=tests/e2e/smoke.scenario.mjs BASE=<printed URL> bash .offrails/tools/browser-smoke/run.sh login` passes. **Apps without accounts** (`ACCOUNTS: none`) have no login to prove: run `run.sh a11y` against the printed URL instead (its preflight fails on an untrusted certificate, so a pass proves the browser trusts it). The smokes need **Node ≥ 22.12** (Node 24 LTS recommended); `run.sh` refuses older. Never tell a human to click through a certificate warning.
3. **`serve`: one bind, drain, sniffing acceptor, dev http port.** *Detect:* `grep -n 'bind_rustls' src/main.rs`, or `grep -n 'from_secs(30)' src/main.rs`, or no `src/kernel/tls.rs`. *Final shape:* [new](generators/new.md) step 4.12, `kernel/tls.rs` ([deployment](guides/deployment.md#direct-tls-optional)), `Config.shutdown_drain` (`APP_SHUTDOWN_DRAIN_SECS`) and `Config.dev_http_port` (`APP_DEV_HTTP_PORT`, refused outside development) in `Config` and `.env.example`; the CSRF layer trusts `http://localhost:<port>` when it's set. *Converge:* write `kernel/tls.rs` with its tests, replace the branch, and add a config test for both variables. Prove it: `curl -si http://localhost:$PORT/` on the TLS port answers `307`.
4. **Dark mode.** (UI apps.) *Detect:* `grep -rLn 'dark:' templates/layouts templates/shared` lists files, or `grep -c 'color-scheme' templates/layouts/application.html` is 0, or no `tests/app/theme_test.rs`. *Final shape:* the palette, skeletons and component layer in [views](guides/views.md#dark-mode); `kernel/css.rs` and `theme_test.rs` from [test-standards §11](guides/test-standards.md#11-theme-and-css-guards). *Converge:* add the guards first and let them list the work (rule a/b/c with file:line), fix every template and `@apply` list, rebuild the CSS, then run `run.sh a11y` (or the app's Playwright a11y suite) in light and dark. When the stale-CSS guard lists a class no rule exists for, first ask whether anything uses it: **delete a dead class from the template** (most are leftovers); rebuild the CSS for a real one; only a JS/test hook class goes in `HOOK_CLASSES`, with its reason. TS modes: apply the palette to `web/src` components and extend the guard to `className=`.
5. **Stale-CSS banner** (UI apps). *Detect:* `grep -rn css_warning src` finds nothing (the app has no banner at all), or `Ctx.css_warning` is only `app_css_problem()`. *Converge:* add `kernel::css::css_warning()` and the `Ctx.css_warning` field, set only in development and rendered first in `<main>`, per [dev-tools](guides/dev-tools.md#introspection).
6. **Hot reload and introspection** (development only). *Detect:* no `/dev/introspect.json` in `ROUTES`, or no `static/dev-reload.js`. *Final shape:* `kernel/introspect.rs`, the two `dev.rs` handlers with `ROUTES` rows (`Access::Dev`), both paths exempt from the pending-migrations guard **if the app has one** (`grep -n 'fn guard' src/controllers/migrations.rs`; an app without the development pending-migrations page has nothing to exempt, and gets the page only when it adopts [dev-tools](guides/dev-tools.md#pending-migrations)), the layout's `{% if ctx.dev %}` script tag, `app introspect` in `main.rs`, and `[profile.dev]` in `Cargo.toml`. *Converge:* copy `kernel/introspect.rs` and the handlers from [dev-tools](guides/dev-tools.md#introspection) (whole files since 0.6.2) and [#hot-reload](guides/dev-tools.md#hot-reload); add both `kernel_web_test.rs` tests: present in development without secrets, 404 in production **and** test.
7. **Smoke scenario** (Node ≥ 22.12 to run it). *Detect:* no `tests/e2e/smoke.scenario.mjs`, or a scenario whose `login` posts with `fetch` instead of the real form. *Converge:* export `login(page, base)` (fill and submit the form), `loggedIn(page)`, `a11yPaths`, `a11yAuthPaths`; run `run.sh login` and `run.sh a11y` against the URL `bin/dev` printed. Without accounts: only `a11yPaths`, and only `run.sh a11y`.
8. **Per-stream pub/sub** (apps with signals). *Detect:* `grep -n 'tx: broadcast::Sender<Signal>' src/kernel/pubsub.rs`, or `grep -rn 'pubsub.subscribe()' src`. *Converge:* replace `tx` with the `topics`/`prefixes` maps; `subscribe(&stream)` at every stream endpoint; `hub.watch_revocations(pubsub.subscribe_prefix("member:"))` in `AppState::new`; `poll_once` dispatches per stream and sweeps; add `topic_count()` and the per-stream and sweep tests ([realtime](guides/realtime.md#kernel-spec-srckernelpubsubrs)).
9. **Keyed revocations** (apps with channels). *Detect:* `grep -n 'revocations: broadcast::Sender<String>' src/kernel/hub.rs`. *Converge:* a per-key sender map, swept on unregister, plus the keyed wake-up test ([channels](guides/channels.md#the-hub-kernelhubrs)).
10. **Render once per version** (apps with live regions). *Detect:* `grep -rn 'hx-trigger="sse:' templates` finds regions while `grep -n 'fn version' src/kernel/pubsub.rs` and `grep -rn 'try_get_with' src/controllers` find nothing. *Converge:* add `observed` and `Topic.version` to pubsub, bumped in `poll_once` before the send; `AppState.fragments`; in each live region's GET, authorize first, then `try_get_with((stream, version), render)`, with `?q=` bypassing the cache; add the two test templates.
11. **Overload is 503, not 408** (every app). *Detect:* `grep -n 'REQUEST_TIMEOUT' src/routes.rs` finds the timeout status, or `grep -n acquire_timeout src/kernel/db.rs` finds nothing, or `grep -n Unavailable src/error.rs` finds nothing. *Converge:* `TimeoutLayer::with_status_code(StatusCode::SERVICE_UNAVAILABLE, …)` plus `map_response(error::retry_after)` outside it; `opts.acquire_timeout(5s)`; `AppError::Unavailable` and its `From<DbErr>` arm (pool timeout → 503 + `Retry-After`) ([errors](guides/errors-and-logging.md#response-mapping)).
11b. **The live re-fetch gate** (only apps with live regions). *Detect:* `grep -rlE 'sse-connect|hx-trigger="sse:' templates` lists files **and** `grep -rn X-Live-Refresh templates` finds nothing. No live regions: skip this step. *Converge:* `hx-headers='{"X-Live-Refresh": "1"}'` on live regions; `kernel::live::gate` with `refresh_permits` and `Config.refresh_wait` ([realtime](guides/realtime.md#keep-re-fetches-from-starving-user-requests)).
12. **SSE presence** (if the app counts viewers over SSE). *Detect:* a per-stream presence count that sends on every join or leave, with no flush ticker. *Converge:* `kernel/presence.rs` per [channels](guides/channels.md#presence-over-sse-no-websocket), with its tests.
13. **Reload after a mutation.** *Detect:* `grep -n 'location.reload' static/app.js` outside the `pageshow` guard (and `dev-reload.js`). *Converge:* an optimistic DOM move, then `htmx.ajax` into the live region.
14. **Rate limits.** *Detect:* `grep -n 'fetch_add' src/kernel/rate_limit.rs` with no `load` check before it, any `expire_after_update`/`time_to_idle` in that file, or `grep -n 'RateLimiter::default()' src/state.rs`. *Converge:* refuse before incrementing; keep expiry on create only; add `RateLimiter::new(env)`, `DEV_LIMIT = 1000` and `limit()` used by `check` and `exhausted`; add the `kernel_test.rs` cases (development 1000 vs test/production 10; a short window that unlocks on time while hammered).
15. **Specs and env.** *Detect:* `grep -nE '^\|[^|]*\|[[:space:]]*(n/a|N/A|none|—)[[:space:]]*\|' product/features/*.md`, or offrails-check's `[security] … says "n/a" with no reason` or `[env] .env.example is missing variable(s)`. *Converge:* `| <Category> | n/a: <why it can't happen here> | — | — |` (a real threat gets its test, never `—`); add each missing variable to `.env.example` (`NAME=value` or `# NAME=value`); a non-env `"SCREAMING_SNAKE"` literal in `config.rs` gets `// not an env var`.
16. **Scripts and CI calling `offrails`** non-interactively. *Detect:* `offrails upgrade`/`offrails new` in scripts or CI without `--yes`. *Converge:* add `--yes` (or `OFFRAILS_YES=1`); without it they now exit 3 and start no agent.


## 0.6.0 (2026-10-13)

### What's new

- **Live pages no longer stall past about 6 tabs:** `serve` speaks HTTP/2 over direct TLS, and `bin/dev` serves HTTPS by default.
- **`tools/offrails-check`** (`offrails check`): a script that fails on an inconsistent `AGENTS.md` header, uncited acceptance criteria, feature specs without a STRIDE table, missing tenancy suites and unscoped lookups. It is the first step of the Definition of Done and of CI.
- **Tenancy enforced by the compiler and tests:** with accounts, model lookups take the actor (`find_for`, `all_for`, `destroy_for`), and every app with accounts gets `tenancy_test.rs` and `guard_test.rs`.
- **Fixes from apps that shipped "green":** a user locked out at about 5 tabs (connection cap 8, now 128), pages loading without htmx, a fresh database crashing `serve`, and a logged-out stream redirecting instead of answering 401.
- **`offrails upgrade --check --fw <dir>`** checks or upgrades against any release and exits 10 when an upgrade is due.
- **Rules for weaker models:** a `NEVER` list in the app's `AGENTS.md`, a command for every Definition of Done item, and evidence before any retry, sleep or raised limit.

### Breaking changes

- Direct TLS: new `APP_TLS_CERT`/`APP_TLS_KEY` settings, `bin/dev-cert`, and `bin/dev` serving HTTPS unless `APP_TLS=off`.
- Apps with accounts: raw model `find`/`destroy` become `pub(in crate::models)`, so handlers must move to the scoped functions (the compiler lists every caller), and the two tenancy suites are required.
- `offrails-check` runs first in the Definition of Done and in CI, and fails until the app passes it.
- A logged-out request to `/streams/…` answers 401, not 303 to the login page.

### Upgrading

The numbered **App migration** list (steps 1–14) after the entries, for 0.3.x, 0.4.x and 0.5.0 apps, plus the instant-navigation note at the end. The procedure is in [upgrade](generators/upgrade.md).

### Full list of changes

Hardening from a 0.3.0 Kanban built by another agent that shipped "green" with three P0 defects. The rule this release follows: **mechanical over prose**. Each fix is a command that proves it, a template, a compile error or a failing check.

- **Fixed: live pages stalled past ~6 tabs.** Browsers allow about 6 HTTP/1.1 connections per origin across all tabs, and each live page holds one or two open (signals + socket). The 7th tab's stream, then its page loads, queued forever. Browsers only speak HTTP/2 over TLS, so `serve` now does **direct TLS with HTTP/2** (`axum-server` + rustls, ALPN `h2`, `APP_TLS_CERT`/`APP_TLS_KEY`), `bin/dev` serves **HTTPS by default** (`bin/dev-cert` makes a local certificate with mkcert or openssl; `APP_TLS=off` for http), and `APP_URL`'s scheme follows. Measured in the reference app: HTTP/1.1 5 of 12 tabs updated; HTTP/2 12/12 and 40/40 ([realtime](guides/realtime.md#many-tabs-stall-http11-vs-http2)). A multi-tab smoke ships with it (`tools/browser-smoke/run.sh live-tabs`).
- **Fixed: the per-user connection cap (8) locked a user out at ~5 tabs** (429 on `/streams`). It's 128 now: sized for tabs, still bounded.
- **Fixed: pages randomly had no htmx and the live badge stayed on "connecting…"** (0.3.x apps). `assets()` picked `"vendor/htmx-"` by prefix from a `HashMap`, which also matches `vendor/htmx-ext-sse-…`; depending on iteration order a page got the sse extension twice and no htmx. Assets now use exact keys (`vendor/htmx-2.`), a test asserts each key matches exactly one file, and the catalog is built once at boot ([frontend-htmx](guides/frontend-htmx.md#assets--cache-busting-kernelassetsrs); CHEATSHEET gotcha 17).
- **Scoped lookups:** with accounts, the public model lookups take the actor (`find_for(db, &user, id)`, `all_for`, `destroy_for`); raw `find`/`destroy` are `pub(in crate::models)`, so a handler can't compile an unscoped call ([models](guides/models.md#scoped-lookups-tenancy)).
- **Tenancy suites for every app with accounts** (not just teams): `tests/app/tenancy_test.rs` requests every id route in `ROUTES` with another tenant's ids (404, or the status an AC specs), and `tests/app/guard_test.rs` fails on unscoped lookups in handlers. Both are templates in [test-standards §4](guides/test-standards.md#4-tenant-isolation-suite-every-app-with-accounts), generated by `authentication`, extended by `scaffold`.
- **`tools/offrails-check`** (also `offrails check`): a POSIX script that fails on an inconsistent header (`ISLANDS: yes` without `UI_LIBRARY`/`web/`, `FRAMEWORK` behind `.offrails/VERSION`), any uncited `AC-…`, a feature without the STRIDE Security table or naming a test that doesn't exist, missing tenancy suites, and unscoped lookups; it warns on a stale `docs/routes.md`. First step of the Definition of Done, in generated CI, `/offrails:check` and `/offrails:doctor`. The framework's CI tests it against a passing tree and a failing fixture per rule.
- **Weak-model rails:** a `NEVER` list atop the app's `AGENTS.md`; a command for every Definition of Done item; **evidence first** (a retry, watchdog, reload cap, `sleep` or raised limit needs the captured failure and a decision line citing it); `docs/decisions.md` lines are superseded, never edited; upgrades reconcile stale decisions.

From Railboard, a Kanban built by headless Claude on 0.5.0:

- **Fixed: a fresh database crashed `serve` at boot** (`no such table: broadcasts`): realtime's pub/sub read its table before the pending-migrations page could render, and `bin/dev` hid it by migrating first. Realtime now starts only once migrations are current (otherwise it waits like the worker and starts after the page's button runs them), and new apps get a fresh-database smoke ([dev-tools](guides/dev-tools.md#pending-migrations), [realtime](guides/realtime.md#kernel-spec-srckernelpubsubrs)). Reproduced and fixed in the reference app.
- **Fixed: a logged-out stream answered 303 to the login page.** `EventSource` can't follow it, so it retried forever, and the stream path became the after-login page. `/streams/` and `Accept: text/event-stream` now get the API flavor: 401, no redirect, no `return_to` ([errors-and-logging](guides/errors-and-logging.md)). Fixed in the reference app.
- Presence: every (re)join gets a snapshot that includes the joiner before any diff; the client buffers diffs until it arrives; reconnects jitter; after a restart, both clients must see each other within backoff + 1 s ([channels](guides/channels.md#the-hub-kernelhubrs)).
- `/dev/sockets`' frame log is specified (types, limits, where frames are recorded, tests) instead of only being promised.
- Islands develop with `vite build --watch`, never the Vite dev server: the dev CSP stays the production CSP, one origin over HTTPS ([frontend-htmx](guides/frontend-htmx.md#islands)). `spa` dev serves the Rust app over http behind Vite's proxy.
- Non-interactive agent runs (`claude -p`, `opencode run`) stop every server they started before finishing, or the run never exits ([cli](cli.md#running-the-app-from-an-agent)).
- `tools/brand/render.sh` works without Chrome: `rsvg-convert` rasterizes the SVGs, else a neutral set from `tools/brand/default/` is copied.
- ts-rs without `.cargo/config.toml`: per-type `#[ts(export, export_to = …)]` and `#[ts(type = "number")]` on every `i64` ([frontend-inertia](guides/frontend-inertia.md)).
- Models rule 8: inserts that can hit a unique index use `on_conflict_do_nothing_on(..)` (SeaORM 2.0.4 `TryInsertResult`), never insert-then-catch ([models](guides/models.md#rules)).

From the website's upgrade to 0.5.0:

- `offrails upgrade [--check] --fw <dir>` checks or upgrades against any release (a tag export, the plugin root), not only `~/.offrails/current`; `--check` exits 0 when current, 10 when an upgrade is due ([upgrade](generators/upgrade.md)).
- The Definition of Done and testing.md say what to do when localhost:5432 is taken: run the compose `db` on another port (`DB_PORT=54329 docker compose up -d db`) and point `TEST_DATABASE_URL` at it; never stop someone else's Postgres.

**App migration** (0.3.x, 0.4.x and 0.5.0 apps; reconcile: *detect* → *final shape* → *converge*, one slice each, green between them):

1. **Run the check.** `sh "$FW/tools/offrails-check"` (before re-vendoring) lists most of the work below. After re-vendoring it's `sh .offrails/tools/offrails-check`, and it must exit 0 at the end.
2. **HTTP/2 + TLS.** *Detect:* `grep -n bind_rustls src/main.rs` finds nothing, or `bin/dev` has no `APP_TLS`. *Final shape:* `Config.tls: Option<TlsFiles>` from `APP_TLS_CERT`/`APP_TLS_KEY` (both or neither); `serve` branches to `axum_server::bind_rustls` (crypto provider installed first, graceful shutdown through a `Handle`); `bin/dev-cert`; `bin/dev` exports the pair unless `APP_TLS=off`; dev `APP_URL` is `https://` with TLS; both vars commented in `.env.example` ([new](generators/new.md) step 4.12, [deployment](guides/deployment.md)). *Converge:* `cargo add axum-server --features tls-rustls` and `cargo add rustls --no-default-features --features aws_lc_rs,std`, then the code. Prove: `curl -sk -o /dev/null -w '%{http_version}\n' https://localhost:<port>/up` prints `2`, and `run.sh live-tabs` passes with 12 tabs.
3. **Connection cap.** *Detect:* `max_conns_per_user: 8`. *Converge:* 128, and the cap test reads the configured value instead of a literal.
4. **Assets (0.3.x).** *Detect:* `grep -n 'vendor/htmx-"' src/kernel/assets.rs`, or a catalog built per request (`AssetCatalog::load` in an extractor). *Final shape:* `ASSET_KEYS` with exact prefixes (`vendor/htmx-2.`, `vendor/htmx-ext-sse-`), the uniqueness test, the catalog in `AppState` (rebuilt per request only in development). *Converge:* per [frontend-htmx](guides/frontend-htmx.md#assets--cache-busting-kernelassetsrs).
5. **Live-region workarounds.** *Detect:* stall watchdogs, reload caps or reconnect retries in `static/app.js` added for tabs that stopped updating. *Converge:* after step 2, reproduce with `run.sh live-tabs`; if it passes without them, remove them and supersede their decision lines. Keep the 0.5.0 `ping` watchdog (it covers dead proxies, not tabs).
6. **Scoped lookups** (`TENANCY` isn't `none`). *Detect:* `offrails-check`'s `[guard]` lines. *Final shape:* the table in [models](guides/models.md#scoped-lookups-tenancy). *Converge:* per model, add `find_for`/`all_for`/`destroy_for`, make `find`/`destroy` `pub(in crate::models)`, let the compiler list every caller, and move each to the scoped fn (tests read through `Entity::find_by_id`, a `reload_<model>` factory helper). Mark lines that are public by spec `// unscoped: <AC id>`.
7. **Tenancy suites.** *Detect:* no `tests/app/tenancy_test.rs` or `guard_test.rs`. *Converge:* copy both from §4, fill `Tenant`, `EXPECTED` (from the ACs), `NOT_TENANT_IDS`, `fill` and `call`, register them in `tests/app/main.rs`, then prove the suite bites (remove one policy check, watch it fail, restore). A failure here is a real cross-tenant bug: fix the handler, never the expectation, unless an AC says the route is shared.
8. **Specs ↔ tests.** *Detect:* `[ac]` and `[security]` lines. *Converge:* write the missing tests; turn prose Security sections into the STRIDE table ([feature-spec](blueprints/feature-spec.md)) with a real test name per threat; tick ACs whose tests pass on both databases.
9. **Header.** *Detect:* `[header]` lines. *Converge:* make `ISLANDS`, `UI_LIBRARY` and `FRONTEND_MODE` describe the code that exists (record a decision if you change a mode), and set `FRAMEWORK` to `.offrails/VERSION` after re-vendoring.
10. **Agent rails.** Add the `NEVER` list from [`blueprints/AGENTS.md`](blueprints/AGENTS.md) to the app's `AGENTS.md`, add `sh .offrails/tools/offrails-check` as the first step of the SQLite CI job ([testing](guides/testing.md#ci)), and reconcile `docs/decisions.md` ([upgrade](generators/upgrade.md) step 8).
11. **Boot on a fresh database** (apps with channels or signals). *Detect:* `serve` awaits `pubsub.start()` (or `cluster::start`) unconditionally. *Converge:* the gated start in [realtime](guides/realtime.md#kernel-spec-srckernelpubsubrs) sharing the worker's `wait_for_migrations`; run the fresh-database smoke.
12. **Logged-out streams.** *Detect:* a logged-out `GET /streams/<x>` answers 303. *Converge:* `/streams/` and `Accept: text/event-stream` take the `Api` flavor in `ResponseFlavor::of`; the stream auth test expects 401 with no `Location` and no `_return_to` cookie.
13. **Presence.** *Detect:* `presence_here_frame` computed before `hub.track`, or a client `Presence` that applies diffs before `here`. *Converge:* per [channels](guides/channels.md#the-hub-kernelhubrs); add the restart convergence test.
14. **Islands** (`ISLANDS: yes`). *Detect:* `localhost:5173` in the dev CSP or `bin/dev`. *Converge:* `vite build --watch` in `bin/dev`, the manifest path in development, and the production CSP everywhere.

- Instant navigation recipe (`guides/frontend-htmx.md`), gotcha from CI: wrap `document.startViewTransition` so a transition skipped by a quick second click doesn't leave unhandled `AbortError` rejections (htmx drops the transition object); make the last boosted click win. **App migration:** apps that adopted the recipe: *detect* `globalViewTransitions` without a `startViewTransition` wrapper in `static/app.js`; *converge* per step 6 of the recipe.

## 0.5.0 (2026-10-13)

### What's new

- **An accessibility gate:** axe-core (WCAG 2.2 AA) on every route, in light and dark, on desktop and mobile, with a required `a11y` CI job.
- **Less for agents to read:** a two-page [`CHEATSHEET.md`](CHEATSHEET.md) is the first thing to load, and every guide and generator opens with a TL;DR.
- **Robust live regions:** no leaking `EventSource`s, a `ping` heartbeat with a watchdog, `hx-sync`, a connection badge and one stream per page.
- **Instant navigation for content sites** (optional): View Transitions, a CSS-only progress bar, a service worker and prefetch on intent.
- **Repairing a migration:** the repair's `down` restores the earlier schema, so a full rollback still works.
- rustoffrails.org runs on the framework, with a hosted read-only MCP server at https://rustoffrails.org/mcp.

### Breaking changes

None required. The docs now call the compose Postgres service `db`, so scripts that run `docker compose up -d postgres` should use `db`.

### Upgrading

The **App migration** notes at the end of this section: nothing required; apps with a UI add the accessibility suite, and apps with live regions apply the live-region fixes. The procedure is in [upgrade](generators/upgrade.md).

### Full list of changes

Learned from real apps built by other agents and from a real upgrade: an accessibility gate in both themes, robust live regions (from a Kanban built with opencode), a context diet for agents (`CHEATSHEET.md` + a TL;DR on every guide), instant navigation for content sites, and a migration repair convention. rustoffrails.org runs it, with a hosted read-only MCP server at https://rustoffrails.org/mcp.


- Docs: the Postgres compose service is `db` everywhere (`docker compose up -d db`); `fingerprint.sh` ignores a `.git` *file* (worktrees, submodules); the Copilot pointer file has an exact body; `generators/upgrade.md` defines a non-interactive path; the pre-deploy checklist requires green CI on the exact commit.

- Fixes from a real 0.3.0 → 0.4.0 upgrade (bookshelf, via `/offrails:upgrade`): `fingerprint.sh` and vendoring ignore the plugin cache's `.in_use/` marker; the error-page `app.js` snippet is now shown in errors-and-logging; ZAP's "Suspicious Comments" gotcha; the 0.4.0 migration notes' detect lines for #2, #3 and #6 are greppable (and `HX-Push-Url` lives at the end of `error::shape_errors`); the test-harness DB helpers are named (`create_database`, `drop_database`); a macOS ZAP variant; a deterministic first-login transaction test; the upgrade recipe adds a missing "Post-upgrade checks" section.

- **Robust live regions** (`guides/realtime.md`), from a Kanban built with opencode + Muse Spark on 0.3.0: never reconnect by editing `sse-connect` (it leaks `EventSource`s), the ~6-per-origin connection budget (one stream per page), `hx-sync` on live regions, a named `ping` heartbeat event (+ a hidden `sse-swap="ping" hx-swap="none"` observer and a 60s watchdog), a connection-state badge, and the `pageshow` guard in the app layout.
- **Context diet:** a two-page [`CHEATSHEET.md`](CHEATSHEET.md) (stack, layout, naming, DoD, security non-negotiables, the 15 costliest gotchas, commands) is now step 0 of the load order, and every guide and generator opens with a **TL;DR** (what to do, what never to do, when to read the rest). Agents read a full guide only when the task touches it. No app migration.
- **Accessibility gate** (`guides/test-standards.md` §10): axe-core (WCAG 2.2 AA) on every route, in light **and** dark, desktop and mobile; keyboard, motion and form rules; a required `a11y` CI job. Lighthouse checks one color scheme only, which let a light-theme contrast bug through on rustoffrails.org.
- **Instant navigation for content sites** (`guides/frontend-htmx.md`, optional recipe, from rustoffrails.org): View Transitions (cross-document + htmx `globalViewTransitions`) with a fixed header, a CSS-only progress bar via `hx-indicator`, focus to the new `h1` after boosted navigations, a per-deploy versioned stale-while-revalidate service worker (precache, theme-cookie rewrite, offline page, never caching `no-store`/cookie responses), and prefetch on intent. Optional; not for apps with per-user pages.
- **Migrations:** repairing an earlier migration: the repair's `down` restores the predecessor's exact schema, so full rollback works (`guides/migrations.md` rule 2).

**App migration:** none required. If your app's docs or scripts say `docker compose up -d postgres`, use `db`.

Apps with a UI: *detect* no axe suite, or one that runs a single color scheme. *Converge:* add the §10 suite and the `a11y` CI job, then fix what it finds.

Apps with live regions: *detect* `sse-connect` attributes changed from JS, live regions without `hx-sync`, or a stream with no named `ping` event. *Converge* per "Robust live regions"; add the `ping` merge to the stream endpoint and the badge + watchdog to `static/app.js`; add the `pageshow` guard to the layout's script. Apps upgraded from 0.3.0 should double-check 0.4.0 note #5 (the first-login race: a caught unique violation aborts a Postgres transaction).


## 0.4.0 (2026-10-13)

### What's new

- **Realtime has its own wire protocol, offrails.v1:** typed channels, self-describing JSON frames and stable error codes, replacing the Phoenix v2 format.
- **Nothing is lost while a tab is disconnected:** signals carry ids, pages render a cursor, and reconnects replay what was missed. Live regions also keep working after a boosted navigation.
- **Automatic transport fallback:** WebSocket, then SSE + POST, then long-polling.
- **Connection security:** logout, a password change or a revocation closes that session's sockets and streams at once; pushes are re-authorized, and each user has connection and join caps.
- **Upgrade from any agent:** `offrails upgrade` launches Claude Code, opencode or Codex with the agent-neutral [upgrade](generators/upgrade.md) recipe, and vendoring uses anchored excludes from a release tag.
- **Magic-link sign-in verified** by a fresh agent's build, with its doc findings fixed.

### Breaking changes

- **Apps with channels:** client and server must switch to offrails.v1 together; old clients get `bye` `protocol` and close 1002. Rooms become typed `In`/`Out` enums.
- **Apps with SSE:** `Signal` gains an `id`, pages with `sse-connect` render a `?since=` cursor, and the sse extension moves from a page's head block into the layout.
- New settings `APP_REALTIME_REVALIDATE_SECS` and `APP_REALTIME_TRANSPORTS`.
- Smaller fixes to adopt: raw SQL in controllers and tasks moves into `kernel::db`, panics render the 500 page, the pending-migrations guard goes after the fallback, and `/dev/mailers` answers 301.

### Upgrading

The two numbered **App migration** lists below (realtime, steps 1–7; the Linkdrop fixes, steps 1–7) and the vendoring note. The procedure is in [upgrade](generators/upgrade.md).

### Full list of changes

Realtime gets its own wire protocol, **offrails.v1**: typed channels, sequence-based resume after downtime (WebSocket `since`, SSE `Last-Event-ID`), automatic fallback to SSE + POST and then long-polling, and connection security (logout or a password change closes that session's sockets and streams; per-user caps; re-authorized pushes). The security hardening was merged with it and re-verified end to end in the reference app (233 tests on Postgres, ZAP 0 warnings, cargo-deny, gitleaks, browser smoke). Apps can be upgraded from any agent with **`offrails upgrade`** (Claude Code, opencode, Codex) and the agent-neutral **`generators/upgrade.md`**; vendoring now uses anchored excludes from a release tag. The **magic-link** accounts variant is verified by a fresh agent's build (Linkdrop), and its 24 doc findings are fixed below.

#### Realtime: the Off Rails Wire protocol (offrails.v1), resume after downtime, transport fallback, connection security

Spec: `guides/channels.md` (protocol, transports, security), `guides/realtime.md` (resume). Verified in the reference app: 223 tests on SQLite and Postgres 18 (39 realtime: conformance, security, all three transports, a 3-node failover), two Playwright specs (a server restart with changes made while it was down; WebSocket → SSE → long-polling), and a two-context Chrome session.

- **Fixed: signals sent while a tab was disconnected were lost** (a deploy, a restart, a blip): live regions silently went stale. Every signal now carries its id; pages render a cursor (`?since=`); reconnects replay what was missed (or `refresh`); `app.js` carries the cursor across htmx-ext-sse's reconnects.
- **Fixed: live regions were dead after a boosted navigation.** The sse extension was loaded in a page's own `<head>`, which a boosted swap never runs. It's in the layout now.
- **Our own protocol, offrails.v1** (replaces the Phoenix v2 array format): negotiated subprotocol, self-describing JSON object frames (`join`/`leave`/`push`/`ping` up; `ok`/`err`/`event`/`presence`/`pong`/`slow`/`bye` down), stable error codes, `bye` before every close, `seq` resume, WebSocket ping control frames instead of app heartbeats.
- **Typed channels:** rooms declare `In`/`Out` serde enums (`#[derive(TS)]` in TS modes); undeclared events are refused before the room runs.
- **Transport fallback:** WebSocket → SSE + POST → long-polling, chosen automatically, remembered per tab, upgraded back when possible. `APP_REALTIME_TRANSPORTS` lists what the server offers.
- **Connection security:** logout, password change and revocation close that session's sockets and streams at once (4401), across processes and nodes; periodic revalidation closes expired sessions (`APP_REALTIME_REVALIDATE_SECS`); pushes re-authorized against current state; per-user caps (connections, topics, joins/min); presence never carries emails; cross-site refused on every transport.
- **`/dev/sockets`** shows each connection's transport and a live decoded frame log (development only).

**App migration** (reconcile, don't re-create; apps without channels do steps 1–3 only):

1. **SSE resume.** Detect `pub struct Signal { pub stream, pub event }` in `src/kernel/pubsub.rs`. Final shape: `Signal { id, stream, event }` (row id), plus `pubsub::high_water` and `pubsub::missed` (realtime.md#kernel-spec). Converge: add the field (set it in `poll_once`), add the two functions; in `channels::stream` take `Query<StreamQuery>` + `HeaderMap`, subscribe first, replay `missed` with ids (or `refresh`), send `ready` with the cursor, add `.id(sig.id)` to live events, skip only exact replayed ids. No migration (the `broadcasts.id` column already exists).
2. **Pages render a cursor.** For every page with `sse-connect`: read `pubsub::high_water(&db)` before the page's data and render `sse-connect="…?since={cursor}"`. Add the `createEventSource` override and the `bye` listener to `static/app.js` (realtime.md#client-side). Inertia/SPA: pass the cursor as a prop and use it in `?since=`.
3. **The sse extension in the layout.** Detect `htmx_sse` inside a template's `{% block head %}`: move the script tag into `templates/layouts/application.html` (after htmx) and delete the block. Add the "every page loads the live-update extension" test.
4. **Channels: switch client and server together** (the formats are incompatible, and old clients now get `bye` `protocol` "subprotocol offrails.v1 required" + close 1002). Replace `kernel/hub.rs`, `kernel/channels.rs` and `static/channels.js` (or `web/src/lib/socket.ts`) per channels.md; convert each room to typed `In`/`Out` enums, `joined(.., since)` returning `Joined`, `handle_in(.., msg: Self::In, out: &mut Outbox<Self::Out>)` returning `Result<Payload, Rejection>`, `out.emit(seq, …)`; rename events on the page (`ch.channel(topic, { since })`, `ch.on("<out event>")`, `ch.push("<in event>")`, errors are `{code, message}`). `cluster.rs` only changes `String` errors to `Rejection`.
5. **Connection security.** `Member` gains `#[serde(skip)] session: Option<i64>`; add `SessionAndUser`; `session::destroy`/`destroy_all_for_user` write the `member:<id>` / `session-ended` signal; `AppState::new` calls `hub.watch_revocations(pubsub.subscribe())` and `hub.log_frames(is_development)`; `Channels` gains `session_valid`; the SSE stream ends with `bye` on `session_ended`. Config: `APP_REALTIME_REVALIDATE_SECS` (60), `APP_REALTIME_TRANSPORTS` (all), in `Config` and `.env.example`.
6. **Fallback transports.** Add the four routes (`/socket/sse`, `/socket/sse/{conn}`, `/socket/poll`, `/socket/poll/{conn}` GET+POST) with `ROUTES` rows, and the handlers calling `kchannels::sse_open` / `relay_post` / `poll_open` / `poll_get`.
7. **Tests:** port `support::Ws` to offrails.v1, add `wire_test.rs` and the resume/session cases in `kernel_pubsub_test.rs`, and `tests/e2e/realtime.spec.ts` (channels.md#testing).

#### Upgrading apps from any agent
- **`generators/upgrade.md`**: the upgrade recipe, agent-neutral (Claude Code, Codex, opencode, Cursor). `/offrails:upgrade` now runs it.
- **`offrails upgrade [--check]`** in an app: compares content fingerprints with the installed framework, then launches Claude Code (`/offrails:upgrade`), opencode or Codex with the recipe, or prints the prompt. `offrails new` supports opencode too.
- **Vendoring:** the `rsync` excludes are anchored (`/skills`, `/hooks`, `/evals`, `/brand`…), so `tools/brand/` is kept, and `--delete` removes files dropped upstream. Vendor a **release tag**, never a moving working tree. `.github/` is vendored on purpose, so fingerprints match the release.

**App migration:** none required. Next time you upgrade, vendor with the anchored excludes (`generators/new.md` step 1); `tools/brand/` reappears in `.offrails/`.

#### Fixes from the Linkdrop build (a fresh agent, magic-link accounts)
- **`kernel::db::ping`** (for `/up`) and **`kernel::db::drop_idle_test_databases`** (for `test_db_cleanup`): no raw SQL outside the kernel.
- **Panics** render the standalone 500 page via `CatchPanicLayer::custom(error::panic_response)`.
- **Pending-migrations guard** is layered after the fallback, so unknown paths show the page too.
- **`/dev/mailers`** answers 301 explicitly (`Redirect::permanent` is 308).
- **Magic links:** `consume` is one transaction returning user, session and token; first-login user creation uses `ON CONFLICT DO NOTHING` (a unique violation aborts a Postgres transaction; a general rule in models.md); `Option<CurrentSession>` needs `OptionalFromRequestParts` in axum 0.8; token pages send `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The variant is now verified (168 tests, both databases, real browser).
- **htmx:** a 422 re-render sends `HX-Push-Url: false`, so the address bar keeps the form's URL.
- **Error pages** load `app.js`, so Forward from a 404 works; test-standards §9 checks it.
- **Tests:** `wait_until(what, f: impl AsyncFn() -> bool)`; `enqueued` returns `(job::Model, W)`; per-email rate limits are tested across IPs with `APP_TRUSTED_PROXY` + `X-Forwarded-For`.
- **Docs:** `#[rustfmt::skip]` on `ROUTES`; kernel pages use no `confirm()` dialogs (`data-confirm` pattern for app pages); the e2e package arrives with the first critical flow; port checks with `lsof -sTCP:LISTEN`; direct TLS is added on first use; dates are UTC; guidance for interview answers relayed by another agent.

**App migration** (reconcile, don't re-create):
1. *Detect:* `grep -rn "execute_unprepared" src/controllers src/tasks`. *Converge:* move each into `kernel::db` (`ping`, `drop_idle_test_databases`) and call those.
2. *Detect:* `CatchPanicLayer::new()` in `src/routes.rs`, **or** a `CatchPanicLayer::custom(|_| …)` closure that ignores the panic payload or doesn't render `errors/500.html`. *Converge:* `CatchPanicLayer::custom(error::panic_response)`, rendering `errors/500.html`; add a test that a panicking handler returns the 500 page.
3. *Detect:* `migrations::guard` is layered **before** `.fallback(...)` in `src/routes.rs` (structural), or, with the app running and a pending migration, `GET /no-such-path` returns 404 instead of the pending page. *Converge:* apply `migrations::guard` after `.fallback(...)`.
4. *Detect:* `GET /dev/mailers` returns 308. *Converge:* return 301 explicitly.
5. Magic-link apps: *detect* a sign-up path that catches a unique violation inside a transaction. *Converge:* `on_conflict_do_nothing_on([Column::Email])` + select. Add `no-store` and `Referrer-Policy: no-referrer` to the token pages, with a test.
6. htmx apps: *detect* a 422 re-render without `HX-Push-Url: false`. *Converge:* set it once, at the end of `error::shape_errors` (it sees every 422, whether a handler rendered it or `AppError::Invalid` did, and covers future forms); add a request test asserting the header on a 422 from an htmx request.
7. *Detect:* `templates/errors/*.html` without `/static/app.js`. *Converge:* add `<script src="/static/app.js" defer></script>`; 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/<version>` 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 <name> "<pitch>"`, `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 "<APP_NAME>" "<accent from the layout>" "<pitch from product/README.md>" static`, add the `<head>` 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_<crate>_<hex>`.
- 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_<crate>_<hex>`, 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<ts>_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 `<a>` (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_<hex>` names in `tests/app/support.rs`. Converge to `test_<crate>_<hex>`, 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).
