Repository navigation
Releases: rust-off-rails/rustoffrails
Release list
Rust off Rails 0.7.3
What's new
- Snippets beside generators.
cargo offrails snippetslists small, tested recipes (an Atom feed, slugs, Markdown, pagination, a theme toggle, backdated seeds, a public page by slug, a CRUD resource);snippet showprints one to read or copy,snippet addcopies and wires it,snippet removetakes 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
destroyor a person can find and remove it. Path parameters work ing controllerandg page. - Polish from one file and a component kit.
brand.tomlwithcargo offrails brandsets 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 writesdocs/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_SIGNUPinsrc/config.rs(orAPP_AUTH_SIGNUP=open|invite) closes sign-up for every sign-in method, andapp task auth_invite <email>lets people in;cargo offrails new … --invitestarts an app that way. - Security review fixes (template, tooling and guides): an unset
APP_ENVnow refuses to boot instead of defaulting to development;APP_TRUSTED_PROXY_HOPSpicks whichX-Forwarded-Forhop is the client behind several proxies;/staticmarks a URL immutable only when its?v=is the file's current digest;Cache::delete_prefixis one escapedLIKEdelete;cargo offrails verifynames every skipped step in its verdict and never probes a local Postgres;--pitchis validated like the title; the Claude Code hook guards canonicalize paths and matchpkillbehind 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 needsAPP_TRUSTED_PROXY=true; blob downloads areno-store; API tokens die with a password reset and expire by default; webhook routes verify signatures; search escapesLIKEwildcards; 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'scargo offrails,verify --fullaccepts a separate coverage toolchain, the browser smokes find any password sign-in form, the Dockerfile stops compiling dependencies twice, andoffrails-checkwarns abouthx-triggerfilters that the CSP setup silently disables.
Breaking changes
- Generators and
snippet addneed two 0.7.3 template changes first, and refuse (naming what's missing) without them: the feature routes anchors insrc/routes.rs(ROUTESbecomesAPP_ROUTES+TABLES; steps 3–4 below), and the component kit (templates/components/ui.htmland its helpers; step 6). - The generated file layout changed. New generations write small files per feature;
destroyof aresource,model,controller,pageorapigenerated before 0.7.3 refuses and lists its files (delete them by hand, thendestroy … --forget). - The
ACCOUNTSheader has new words: sign-in methods joined by+, theninvite(signup→password,invite→password invite,magic-link-invite→magic-link invite).add-partreads 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, nottarget/offrails-gen. APP_ENVis required.app …fails withAPP_ENV is not setwhen neither the environment nor.envsets it. Every server already setsAPP_ENV=production(the Dockerfile does); locallybin/setupwritesAPP_ENV=developmentto.env. CI scripts that ran the binary without it must set it.Configgainstrusted_proxy_hops: u8(default 1);Config::boot_warningsno longer warns about an unsetAPP_ENV, andenv_defaultedis alwaysfalse.- 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 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 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).
- 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 samples2bc93ad(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'soffrails-genfor this one step (upgrade step 6). - 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,--layoutandnew --invitenow work. - Feature routes files (Generators → App migration):
src/routes.rsgetsroute modsandroute tablesanchors andROUTESbecomesAPP_ROUTES+TABLES. Required before the firstgenerate resource|controller|page|apiorsnippet add(they refuse and name the anchor without it). - The route sweeps fill any
{param}, and the mounted-route check readssrc/routes/(same entry): required with the step above. samples: app jsat the end ofstatic/app.js(Snippets → App migration): required beforesnippet add theme-toggle.- 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 beforesnippet add resource|pagination|public-show-by-slug.brand.toml, the other layouts andprovestay optional. - 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 .cargoin the Dockerfile's dependency layer, the.scrollbox instatic/kernel/admin.css. Each is independent. - The
ACCOUNTSheader (Accounts → App migration): rewrite it in the new grammar (signup→password,invite→password invite,magic-link-invite→magic-link invite); an invite-only app setsDEFAULT_SIGNUP = Signup::Invite(after step 1 brought auth-core's switch). - Generations recorded before 0.7.3 keep working as code;
destroyof aresource/model/controller/page/apifrom before refuses and lists its files (Generators → App migration). - Optional polish (Polish and proof → App migration):
brand.toml(cargo offrails brand --init), the layouts, andcargo offrails prove, whichverify --fullruns whenBASEis set. - Security review (Security review fixes, above): Add
trusted_proxy_hops: 1to any hand-builtConfigliteral (Config::test()has it). SetAPP_TRUSTED_PROXY_HOPSwhere more than one proxy appends toX-Forwarded-For. Replace thean_unset_app_env_warns_at_bootunit test withan_unset_app_env_refuses_to_bootintests/app/kernel_test.rs(config.md shows it). - Prove it:
cargo offrails verify --fullis green on SQLite and Postgres,sh .offrails/tools/offrails-checkreports 0 errors,BASE=… cargo offrails provesays proven, anddocs/decisions.mdhasYYYY-MM-DD: upgraded to framework 0.7.3.
Full list of changes
- Snippets: small, tested code to copy (snippets). 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), asnippet.tomlin a part'ssample.tomlformat (plusas, the model--asrenames, andsnippets, the snippets it builds on) andfiles/at app paths.- The eight:
resource(htmx CRUD fornotes, public: exactly the filesg resource notes title:string body:text --publicwrites; 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>, `o...
- The eight:
Rust off Rails 0.7.2
What's new
cargo offrails verifyruns 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-partbrings 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 firstcargo offrails g api. - Parts updated to this release load their page scripts at a new
samples: app headanchor in the layout. split-debuginfoleft[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 is the whole procedure. $FW is the new framework.
- Parts first, before re-vendoring:
update-partmerges 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, thencargo fmt --allandcargo run -q -- db migrate. (An app on 0.7.1 or older has noupdate-partyet: run the new release's,cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" -- update-part <part>… --framework "$FW" --app ..) - Re-vendor:
offrails-gen vendor --framework "$FW".cargo offrails verify,generate,destroyandupdate-partnow work. - 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 firstgenerate. - Route sweeps send each route's own method (same entry): required before the first
cargo offrails g api. - Optional speed-ups (Fast loop → App migration): Postgres test slots, the CSS check cache, the
split-debuginfomove (needed for Windows builds), and the one-job CI. - Prove it:
cargo offrails verify --fullis green on SQLite and Postgres,sh .offrails/tools/offrails-checkreports 0 errors, anddocs/decisions.mdhasYYYY-MM-DD: upgraded to framework 0.7.2plus the generators line.
Full list of changes
- A fast loop:
cargo offrails verify(workflow). 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 (inoffrails-gen, socargo offrails verifyworks in every template app) runs it in parallel:-
--fast(default; after every slice):cargo fmt --check, the CSS build (htmx), clippy-D warningsin 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 readTEST_DATABASE_URL). One line per step with its time and test counts, the tail of each failure, logs intarget/verify/, non-zero exit on any failure. Postgres comes fromTEST_DATABASE_URL(environment or.env) or the compose URL whenlocalhost:${DB_PORT:-5432}answers; otherwise it says skipped. -
--full(once, at the end): alsooffrails-check, coverage (it is the SQLite run, instrumented withcargo llvm-cov nextest --fail-under-lines 80),cargo deny, gitleaks, the no-unwrap/expectreview,routesvsdocs/routes.md, the TS checks when there is aweb/, and the browser smoke whenBASEnames the running app. Missing tools are reported asskipwith the install command, never as passed. -
The guidance changed with it: workflow (the loop's VERIFY step, the Definition of Done, "iterate fast, verify fully once": one test with
cargo nextest run <name>while red,verifyafter each slice, coverage/deny/gitleaks/full browser smokes only in--full), CHEATSHEET,/offrails:check, the appAGENTS.mdblueprint, new step 5,offrails-gen new's next steps,tools/ci/test-template.sh(now runsverify, 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 DATABASEand no migrations per test (testing). Each test used to createtest_<crate>_<uuid>, migrate it and drop it.CREATE DATABASEcopies 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). Nowsupport.rsmigrates once intotest_<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 slottest_<crate>_<tag>_<n>(pg_try_advisory_lock), cloned from the template once and reused across tests and runs, withsynchronous_commit = off, andoffrails_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) andCREATE DATABASE … TEMPLATEper 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 inkernel::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).
kernel::css::css_warningread every template and the built CSS on every development page (~85 ms in a grown app; 97% of the CPU unde...
Rust off Rails 0.7.1
What's new
- New apps start from a tested template.
offrails-gen newcopies 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-partsshows what is installed. - Cross-platform tooling:
offrails-genis a standard-library-only Rust tool, sonew,add-part,list-partsandvendorrun 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.
Full list of changes
-
New apps start from a tested template, not from Markdown retyped token by token (template, new). 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,/devwith 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 everysamples:anchor. It was the samples repo'shost/, 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 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>(orsh "$FW/tools/new-app.sh" …). It copies the template, renames its tokens (offrails_app,offrails-app,Offrails App), vendors.offrails/, writesAGENTS.mdandproduct/README.mdfrom the blueprints, adds the parts, runscargo fmtandgit inits. - 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 vendored into the release (
template/parts/). The agent proposes the ones the product implies, using a decision table (template → choosing parts:dev-dashboardfor every app,mailer+dev-mailboxfor email,jobs+jobs-dashboardfor slow or scheduled work,auth-core+ a method for sign-in,realtimefor 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), thedocs/decisions.mdline, theAGENTS.mdheader (EMAIL,ACCOUNTS),cargo fmt, and the exact next steps. Re-running is a no-op.cargo offrails list-partsshows installed vs available. It is the samples'tools/install.shmechanism in Rust (same format, same record line), andnew --partsuses the same code path. workflow 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; checklist-partsbefore hand-writing kernel code. - Cross-platform generators.
tools/offrails-genis a standard-library-only Rust tool (new,add-part,list-parts,vendor), so it runs the same on Linux, macOS and Windows: nosed -i, paths throughstd::path, CRLF kept where a file has it. The template and the framework carry.gitattributes(* text=auto eol=lf). The app'sbin/scripts andoffrails-checkremain POSIX shell; Windows steps and the known gaps are in template → Windows. - Lean memory defaults, measured.
mainbuilds the Tokio runtime fromConfig:APP_WORKER_THREADS(default 2) andAPP_BLOCKING_THREADS(default 16) instead of one worker per core and 512 blocking threads; SQLite getscache_size1 MiB per connection, a default pool of 4 (DATABASE_POOL; Postgres keeps 10) and a 60 s idle timeout (database, config).tools/ci/memory-budget.shboots 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), 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); step 7b now offers parts and adopts them withadd-part(upgrade).offrails-gen vendor --framework "$FW"re-vendors on any OS. - CI:
tools/ci/test-template.shgenerates 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/expectgate), the suite on SQLite and Postgres,offrails-checkand the memory budget. The framework workflow runs it (template), plustemplate-os: Linux, macOS and Windows generate, build, lint and run the SQLite suite, bare and with every part.test-install.shchecks the release carries the template, the parts andoffrails-genwithout build output; shellcheck covers the new scripts.tools/sync-parts.shrefreshestemplate/parts/from a samples commit (recorded intemplate/parts/SOURCE). - Changed docs: new (generate, then fill the header, parts, verify; a reference of what the template contains, including the anchors table), interview (parts in the plan), samples, upgrade, workflow, doctrine, database (points at the template's
db.rsinstead of a copy), config, README, AGENTS.md (prime directive 8), CHEATSHEET, cli, CONTRIBUTING,blueprints/AGENTS.md,/offrails:newand/offrails:feature. - Not yet: a
scaffoldtemplate (resources are still built from scaffold); full templates forinertia,spaandapi(new --modegenerates the htmx template and logs the conversion, per template → other modes); the guides other thandatabase.mdstill 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.tomlinto the app's.cargo/config.toml(and.gitattributesbeside it), thencargo offrails list-parts. An app generated from 0.7.0's guides ...
- How: after the interview,
Rust off Rails 0.7.0
What's new
- Security fix: development is reachable only from this machine.
servebinds127.0.0.1in 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 newoffrails-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_atstays 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'sDebugprints 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. Upgrading offers them per area.
Breaking changes
- Every app: the development server must bind
APP_BIND(default127.0.0.1in development) and checkHost;offrails-checkfails with[bind]until it does. A dev container, tunnel or LAN workflow now needsAPP_BIND=0.0.0.0plusAPP_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_atcolumn, 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
themetooffrails_theme(read the old one for one release). - Every app also ports the security fixes: job errors stored redacted,
Config'sDebugas 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.
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
servebound0.0.0.0in every environment (cli said so) and nothing checkedHost./devhas no login: the mailbox shows every magic link and reset link, introspection shows the app's state, and/admin/jobsis open in development. So anyone on the same Wi-Fi could openhttp://<your-ip>:3000/dev/mailboxand sign in as you. A page onevil.examplethat re-points its own name at127.0.0.1(DNS rebinding) reached it too: the browser sendsHost: evil.examplesame-origin, so CORS doesn't apply, and Fetch Metadata sayssame-origin, so theCsrfLayerlets its POSTs through. Now, in development only (security, new non-negotiable 13):servebindsconfig.bind:APP_BIND, default127.0.0.1in development and0.0.0.0in production. A dev container setsAPP_BIND=0.0.0.0(config, deployment).kernel/hosts.rs(whole file in the guide, like Rails'HostAuthorization) answers onlylocalhost,127.0.0.1,[::1],*.localhost,APP_URL's host andAPP_ALLOWED_HOSTS(a leading.allows subdomains). Any otherHostgets a 403 page that says how to allow it. It's mounted right inside the security middleware (routing).- An unset
APP_ENVstill means development, butConfig::boot_warningsnow logs that at every boot. On a server it's a missing setting that mounts/dev. - New
offrails-checkrule[bind], with fixtures. It fails a literal0.0.0.0bind insrc/([0, 0, 0, 0],"0.0.0.0:…",Ipv4Addr::UNSPECIFIEDoutsideconfig.rs), aconfig.rsthat never readsAPP_BIND, and an app with no Host check. A0.0.0.0that isn't the server's bind ends its line with// not the server bind.
Also updated: new (steps 4 and 7 and the verify step), cli, dev-tools, the CHEATSHEET, the app
AGENTS.mdNEVER list,/offrails:security, andTestAppgainsget_with_headers/post_form_with_headers. Changed: the macOS port-collision note in cli. A127.0.0.1:$PORTbind succeeds while another process holds*:$PORT, because tokio setsSO_REUSEADDR; thenlocalhostover IPv6 may reach the other app. Verified on macOS 2026-10-16, solsofbefore 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_otherwiseandan_unset_app_env_warns_at_boot, plushosts.rs's two unit tests. The running server listened on127.0.0.1only, refused its LAN IP, answeredHost: evil.testwith 403 and printed the boot warning.App migration (every app; security, do it first):
- Detect:
sh .offrails/tools/offrails-checkprints[bind], orgrep -rnE '\[0, *0, *0, *0\]|"0\.0\.0\.0' srcmatches, orgrep -c APP_BIND src/config.rsis 0. - Converge:
- Add
bind,allowed_hostsandenv_defaultedtoConfig, withbind_address,parse_allowed_hosts,boot_warningsand their two tests, copied from config. Add theAPP_BINDandAPP_ALLOWED_HOSTSlines to.env.example. - In
main.rs, logboot_warnings()afterinit_tracing. Every listener bindsSocketAddr::new(config.bind, port): the main port,APP_DEV_HTTP_PORT, andkernel::server::runif the app has one. - Copy
src/kernel/hosts.rswhole from security, addpub mod hosts;, and mount it just insidesecurity::middlewareinroutes.rs. - Add the three request tests to
security_test.rs, andget_with_headers/post_form_with_headerstoTestAppif they're missing. - Anything that runs development in a container (a dev container, a compose override with
APP_ENV: development) setsAPP_BIND: 0.0.0.0, publishes on127.0.0.1:only, and lists any non-localhost name inAPP_ALLOWED_HOSTS. Production compose and the Dockerfile need no change: they setAPP_ENV=production, which binds0.0.0.0. - A
docs/decisions.mdline, a tunnel or a LAN workflow that relied on0.0.0.0becomesAPP_BIND=0.0.0.0+APP_ALLOWED_HOSTS=<name>in.env. Say so to the human: that network can then read the mailbox.
- Add
- Prove it: the suite on both databases, then with the app running in development:
lsof -nP -iTCP:$PORT -sTCP:LISTENshows127.0.0.1:$PORT(not*:$PORT);curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: evil.test' $URL/devprints403;curl -m 3 http://$(ipconfig getifaddr en0 || hostname -I | cut -d' ' -f1):$PORT/upis refused; starting withAPP_ENVunset logsAPP_ENV is not set;sh .offrails/tools/offrails-checkshowsok [bind].
-
Security: signing in over a session now ends that session and closes its connections (auth, 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::destroyon 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'supdate) don't takeOption<CurrentSession>, or set the new cookie without destroying the old session. Converge: destroy it in the sign-in transaction, beforesession::start. Prove it: a test signs in as A, opens a stream or socket as A, then signs in as B in the sameTestApp: A's connection getsbyerevoked, and A's old cookie, set back withset_cookie, is logged out. -
Security: emailed links are limited per address, with a daily cap (auth). 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' srcfinds nothing. Converge: add the per-address key topasswords::createand 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, variedX-Forwarded-For); the 21st sends nothing (drain_jobs, thensent_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-un...
Rust off Rails 0.6.2
What's new
offrails-checkcatches 32-bit ids ([ids]), which pass on SQLite and break every Postgres write, and test suites that never run on Postgres ([postgres]).bin/devmanages 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 withkill "$(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.rsandkernel/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-checkfails on the new[ids]and[postgres]rules: an app with 32-bit ids adds a widening migration (never edits the applied ones), andtests/app/support.rsmust readTEST_DATABASE_URL.bin/devno longer usestrap '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. This release also corrects the 0.6.1 steps in place.
Full list of changes
- Parallel helpers only after the skeleton compiles (workflow). 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
/tmpjust to type-check their own part. Now: the coordinator builds the skeleton alone, up to the first greencargo checkand 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.rsfiles,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'sAGENTS.mdor 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'sINTEGERis 64-bit, so the suite was green; on Postgres the columns wereINT4and every write that returned an id was a 500. Its tests had never run on Postgres:support.rshard-codedsqlite::memory:. Two newoffrails-checkrules, with fixtures:[ids]fails anid/*_idcolumn defined withinteger/small_integer/tiny_integer(helper orColumnDefchain, across lines,Alias::new("x_id")too) orpk_auto, and ani32/i16id field insrc/;[postgres]fails atests/app/support.rsthat never readsTEST_DATABASE_URL. A migration repaired by a later one carries// offrails-check: ids widened in m<…>(migrations, with thewiden_idstemplate, its SQL verified on Postgres 18). App migration: Detect:sh .offrails/tools/offrails-checkprints[ids]or[postgres]. Converge:[postgres]:TestApp::newper testing first, then run the suite on Postgres and fix what it finds.[ids]: never edit the applied migrations; addwiden_ids(Postgres only;ALTER SEQUENCE … AS bigintforpk_autotables), put the waiver comment in each repaired file, change entity fields toi64, thendb rollback --steps 1 && db migrateand the suite on both databases. - Seeds retry after a half-failed run. The same app's
seed_if_emptyreturned early when any team existed, so a seed that failed after the first team never ran again.tasks/seed.rsnow runs in onekernel::db::begintransaction and finds-or-creates each record by a natural key (cli; scaffold and new updated). App migration: Detect:grep -nE 'count|is_empty|exists' src/tasks/seed.rsgating the whole seed with an early return. Converge: one transaction, find-or-create per record; prove it by runningapp db seedtwice (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/devby its own process; the NEVER list and the hook saidlsof … | xargs killon the listener, which leaves cargo watch and Tailwind running. A peer's agent (dev-92, opencode) also broke the name rule withpkill -fand killed the human's demo: the hook that blockspkillexists only in Claude Code. Nowbin/devwritesstorage/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 plainserveis started assh -c 'echo $$ > storage/pids/serve-<port>.pid; exec cargo run -q -- serve'(cargo runexecs the app, so the PID is the server's; verified). Stop either withkill "$(cat storage/pids/…)"(cli; the NEVER line, CHEATSHEET 14 and the hook message match). Also fixed: 0.6.1'strap 'kill 0' EXITsignals the whole process group, and abin/devstarted 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, orgrep -c 'storage/pids' bin/devis 0. Converge: copy the top ofbin/devfrom dev-tools (PID file, port check,cleanuptrap) and the app'sAGENTS.mdNEVER line fromblueprints/AGENTS.md. Prove it from a non-interactive shell: startbin/devin the background,kill "$(cat storage/pids/dev-$PORT.pid)", thenlsof -nP -iTCP:$PORT -sTCP:LISTENprints nothing, nocargo watch/tailwindcssof 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). App migration: Detect:grep -n 'tailwindcss.*--watch' bin/dev | grep -v -- --minify. Converge: add--minifyto that line. kernel/tls.rsandkernel/introspect.rsare whole files in the guides, not sketches pointing at "the reference app's file", which an upgrading agent can't see. deployment has the fullHttpToHttpsacceptor with its four tests; dev-tools has the fullintrospect.rs(with// APP:marking the realtime part), the two handlers, theapp introspectarm andkernel::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.PubSubgainsstreams()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'sacquire_timeout,AppError::Unavailable) and 11b (only apps with live regions:X-Live-Refreshand 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 isrun.sh a11y. upgrade 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, the 0.6.1 steps, dev-tools). Fixed:
run.shonly 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_CLASSESallowlist, 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; the template's failure message says the same). App migration: Detect: eachHOOK_CLASSESentry. Converge:grep -rn '<class>' static/*.js web/src testsfinds no use → remove the entry and the class from the template; updatetheme_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::upgradesets.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; the methods checked against...
Rust off Rails 0.6.1
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_PORTserves 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.jsonandapp introspecttell an agent the app's routes, config, migrations, jobs and realtime state. - The
offrailscommand is safe to script: every subcommand answers--helpwithout side effects, unknown flags exit 2, andnewandupgradenever 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-checkgains an[env]rule for variables missing from.env.example.
Breaking changes
offrails newandoffrails upgradein scripts or CI exit 3 without--yes(orOFFRAILS_YES=1).bin/dev-certno longer falls back to a self-signed certificate; without mkcert, development serves plain HTTP (or setDEV_CERT_SELF_SIGNED=1). Delete old certificates instorage/dev-certs.- A request timeout or an exhausted database pool answers 503 with
Retry-After, not 408. offrails-checkfails on a missing.env.examplevariable ([env]) and on a baren/aSTRIDE row.- New settings
APP_SHUTDOWN_DRAIN_SECSandAPP_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.
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-certfell back to an openssl self-signed certificate when mkcert was missing. After "Proceed to localhost (unsafe)", Chrome dropsSet-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-certuses mkcert and refuses (exit 3) when mkcert is missing or its CA isn't installed, unlessDEV_CERT_SELF_SIGNED=1;bin/devruns 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). - 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 andAppStateon plain HTTP next to the TLS port.bin/devsets it withDEV_CERT_SELF_SIGNED=1and tells the user to open the http URL; scripts keep h2 on the TLS port. - A bare
localhost:PORTon the TLS port no longer showsERR_INVALID_HTTP_RESPONSE:kernel/tls.rssniffs the first byte and answers plain HTTP with a 307 tohttps://(deployment). - Fixed:
new.mdstep 4.12 bound the port twice (bind_rustls(addr)after binding a listener). Bind once; the TLS branch usesaxum_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) andrun.sh a11y(axe-core 4.13.0, WCAG 2.2 AA, every path in light and dark). Verified: against a forced self-signed certificateloginandlive-tabsfail; over the http fallback and a trusted certificate both pass, andlive-tabsreaches 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, test-standards §9). - Fixed: dark mode shipped unreadable (
bg-whitewith nodark:partner under a body that turned dark: 1.09:1). views 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 templatetests/app/theme_test.rs(test-standards §11): (a) a light surface needs adark:bg-*; (b) dark text needs adark:text-*; (c) faint tones need adark:text-*. It reads the templates and@applylists. 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.rschecks that every template class has its escaped selector instatic/app.css(skipped when unbuilt). The same check (kernel/css.rs) drives a development banner and/dev/introspect.json'scss.stale. In the reference app it listed the 67 newdark:classes until Tailwind ran.bin/devkeeps Tailwind in--watch. - Hot reload in development:
cargo watchrebuilds and restarts on Rust and template edits, Tailwind watches CSS, andstatic/dev-reload.jsreloads open tabs whenGET /dev/reloadchanges (boot id + newest mtime understatic/). 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 skeletonCargo.tomlgets[profile.dev] debug = "line-tables-only"andsplit-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; useCARGO_PROFILE_DEV_DEBUG=true(dev-tools). - Introspection for agents:
GET /dev/introspect.json(development only, absent in production and test, tested) andapp introspectreport 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.mdand the app'sAGENTS.mdtell agents to ask it before guessing (dev-tools). - Fixed:
offrails upgrade --helplaunched the upgrade agent (0.5.0 ignored upgrade's arguments; in 0.6.0,update --helpstill ran the installer andcheck --helpran the checker). Every subcommand now answers-h/--help(andoffrails help <command>) with its own help, exits 0 and does nothing else. Unknown commands and flags exit 2 with usage, andupdatevalidates installer flags before downloading.newandupgradenever start an agent from a non-interactive shell without--yes(orOFFRAILS_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).tools/ci/test-install.shruns-hand--helpon every subcommand with fake agents on PATH and asserts none starts. --fwon an old CLI: an installedoffrailsolder than 0.6.0 silently ignores--fwand compares against~/.offrails/current. upgrade now says to run the target release's own script (sh "$FW/tools/offrails" upgrade --check --fw "$FW") oroffrails updatefirst.- 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 nobye, so browsers reconnect and resume; socketsbye restart1012), then drains for at mostAPP_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). - 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_oncewhere 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)withtry_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-thunder...
Rust off Rails 0.6.0
What's new
- Live pages no longer stall past about 6 tabs:
servespeaks HTTP/2 over direct TLS, andbin/devserves HTTPS by default. tools/offrails-check(offrails check): a script that fails on an inconsistentAGENTS.mdheader, 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 getstenancy_test.rsandguard_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
NEVERlist in the app'sAGENTS.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_KEYsettings,bin/dev-cert, andbin/devserving HTTPS unlessAPP_TLS=off. - Apps with accounts: raw model
find/destroybecomepub(in crate::models), so handlers must move to the scoped functions (the compiler lists every caller), and the two tenancy suites are required. offrails-checkruns 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.
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
servenow does direct TLS with HTTP/2 (axum-server+ rustls, ALPNh2,APP_TLS_CERT/APP_TLS_KEY),bin/devserves HTTPS by default (bin/dev-certmakes a local certificate with mkcert or openssl;APP_TLS=offfor http), andAPP_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). 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 aHashMap, which also matchesvendor/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; CHEATSHEET gotcha 17). - Scoped lookups: with accounts, the public model lookups take the actor (
find_for(db, &user, id),all_for,destroy_for); rawfind/destroyarepub(in crate::models), so a handler can't compile an unscoped call (models). - Tenancy suites for every app with accounts (not just teams):
tests/app/tenancy_test.rsrequests every id route inROUTESwith another tenant's ids (404, or the status an AC specs), andtests/app/guard_test.rsfails on unscoped lookups in handlers. Both are templates in test-standards §4, generated byauthentication, extended byscaffold. tools/offrails-check(alsooffrails check): a POSIX script that fails on an inconsistent header (ISLANDS: yeswithoutUI_LIBRARY/web/,FRAMEWORKbehind.offrails/VERSION), any uncitedAC-…, 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 staledocs/routes.md. First step of the Definition of Done, in generated CI,/offrails:checkand/offrails:doctor. The framework's CI tests it against a passing tree and a failing fixture per rule.- Weak-model rails: a
NEVERlist atop the app'sAGENTS.md; a command for every Definition of Done item; evidence first (a retry, watchdog, reload cap,sleepor raised limit needs the captured failure and a decision line citing it);docs/decisions.mdlines 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
serveat boot (no such table: broadcasts): realtime's pub/sub read its table before the pending-migrations page could render, andbin/devhid 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, realtime). Reproduced and fixed in the reference app. - Fixed: a logged-out stream answered 303 to the login page.
EventSourcecan't follow it, so it retried forever, and the stream path became the after-login page./streams/andAccept: text/event-streamnow get the API flavor: 401, no redirect, noreturn_to(errors-and-logging). 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).
/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).spadev 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). tools/brand/render.shworks without Chrome:rsvg-convertrasterizes the SVGs, else a neutral set fromtools/brand/default/is copied.- ts-rs without
.cargo/config.toml: per-type#[ts(export, export_to = …)]and#[ts(type = "number")]on everyi64(frontend-inertia). - Models rule 8: inserts that can hit a unique index use
on_conflict_do_nothing_on(..)(SeaORM 2.0.4TryInsertResult), never insert-then-catch (models).
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;--checkexits 0 when current, 10 when an upgrade is due (upgrade).- The Definition of Done and testing.md say what to do when localhost:5432 is taken: run the compose
dbon another port (DB_PORT=54329 docker compose up -d db) and pointTEST_DATABASE_URLat 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):
- Run the check.
sh "$FW/tools/offrails-check"(before re-vendoring) lists most of the work below. After re-vendoring it'ssh .offrails/tools/offrails-check, and it must exit 0 at the end. - HTTP/2 + TLS. Detect:
grep -n bind_rustls src/main.rsfinds nothing, orbin/devhas noAPP_TLS. Final shape:Config.tls: Option<TlsFiles>fromAPP_TLS_CERT/APP_TLS_KEY(both or neither);servebranches toaxum_server::bind_rustls(crypto provider installed first, graceful shutdown through aHandle);bin/dev-cert;bin/devexports the pair unlessAPP_TLS=off; devAPP_URLishttps://with TLS; both vars commented in.env.example(new step 4.12, deployment). Converge:cargo add axum-server --features tls-rustlsandcargo 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>/upprints2, andrun.sh live-tabspasses with 12 tabs. - Connection cap. Detect:
max_conns_per_user: 8. Converge: 128, and the cap test reads the configured value instead of a literal. - Assets (0.3.x). Detect:
grep -n 'vendor/htmx-"' src/kernel/assets.rs, or a catalog built per request (AssetCatalog::loadin an extractor). Final shape:ASSET_KEYSwith exact prefixes (vendor/htmx-2.,vendor/htmx-ext-sse-), the uniqueness test, the catalog inAppState(rebuilt per request only in development). Converge: per frontend-htmx. - Live-region workarounds. Detect: stall watchdogs, reload caps or reconnect retries in
static/app.jsadded for tabs that stopped updating. Converge: after step 2, reproduce withrun.sh live-tabs; if it passes without them, remove them and supersede thei...
Rust off Rails 0.5.0
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
a11yCI job. - Less for agents to read: a two-page
CHEATSHEET.mdis the first thing to load, and every guide and generator opens with a TL;DR. - Robust live regions: no leaking
EventSources, apingheartbeat 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
downrestores 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.
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
dbeverywhere (docker compose up -d db);fingerprint.shignores a.gitfile (worktrees, submodules); the Copilot pointer file has an exact body;generators/upgrade.mddefines 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.shand vendoring ignore the plugin cache's.in_use/marker; the error-pageapp.jssnippet 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 (andHX-Push-Urllives at the end oferror::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 editingsse-connect(it leaksEventSources), the ~6-per-origin connection budget (one stream per page),hx-syncon live regions, a namedpingheartbeat event (+ a hiddensse-swap="ping" hx-swap="none"observer and a 60s watchdog), a connection-state badge, and thepageshowguard in the app layout. -
Context diet: a two-page
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 requireda11yCI 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 + htmxglobalViewTransitions) with a fixed header, a CSS-only progress bar viahx-indicator, focus to the newh1after boosted navigations, a per-deploy versioned stale-while-revalidate service worker (precache, theme-cookie rewrite, offline page, never cachingno-store/cookie responses), and prefetch on intent. Optional; not for apps with per-user pages. -
Migrations: repairing an earlier migration: the repair's
downrestores the predecessor's exact schema, so full rollback works (guides/migrations.mdrule 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).
Rust off Rails 0.4.0
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 upgradelaunches Claude Code, opencode or Codex with the agent-neutral upgrade 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
byeprotocoland close 1002. Rooms become typedIn/Outenums. - Apps with SSE:
Signalgains anid, pages withsse-connectrender a?since=cursor, and the sse extension moves from a page's head block into the layout. - New settings
APP_REALTIME_REVALIDATE_SECSandAPP_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/mailersanswers 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.
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 (orrefresh);app.jscarries 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/pingup;ok/err/event/presence/pong/slow/byedown), stable error codes,byebefore every close,seqresume, WebSocket ping control frames instead of app heartbeats. - Typed channels: rooms declare
In/Outserde 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_TRANSPORTSlists 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/socketsshows 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):
- SSE resume. Detect
pub struct Signal { pub stream, pub event }insrc/kernel/pubsub.rs. Final shape:Signal { id, stream, event }(row id), pluspubsub::high_waterandpubsub::missed(realtime.md#kernel-spec). Converge: add the field (set it inpoll_once), add the two functions; inchannels::streamtakeQuery<StreamQuery>+HeaderMap, subscribe first, replaymissedwith ids (orrefresh), sendreadywith the cursor, add.id(sig.id)to live events, skip only exact replayed ids. No migration (thebroadcasts.idcolumn already exists). - Pages render a cursor. For every page with
sse-connect: readpubsub::high_water(&db)before the page's data and rendersse-connect="…?since={cursor}". Add thecreateEventSourceoverride and thebyelistener tostatic/app.js(realtime.md#client-side). Inertia/SPA: pass the cursor as a prop and use it in?since=. - The sse extension in the layout. Detect
htmx_sseinside a template's{% block head %}: move the script tag intotemplates/layouts/application.html(after htmx) and delete the block. Add the "every page loads the live-update extension" test. - Channels: switch client and server together (the formats are incompatible, and old clients now get
byeprotocol"subprotocol offrails.v1 required" + close 1002). Replacekernel/hub.rs,kernel/channels.rsandstatic/channels.js(orweb/src/lib/socket.ts) per channels.md; convert each room to typedIn/Outenums,joined(.., since)returningJoined,handle_in(.., msg: Self::In, out: &mut Outbox<Self::Out>)returningResult<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.rsonly changesStringerrors toRejection. - Connection security.
Membergains#[serde(skip)] session: Option<i64>; addSessionAndUser;session::destroy/destroy_all_for_userwrite themember:<id>/session-endedsignal;AppState::newcallshub.watch_revocations(pubsub.subscribe())andhub.log_frames(is_development);Channelsgainssession_valid; the SSE stream ends withbyeonsession_ended. Config:APP_REALTIME_REVALIDATE_SECS(60),APP_REALTIME_TRANSPORTS(all), inConfigand.env.example. - Fallback transports. Add the four routes (
/socket/sse,/socket/sse/{conn},/socket/poll,/socket/poll/{conn}GET+POST) withROUTESrows, and the handlers callingkchannels::sse_open/relay_post/poll_open/poll_get. - Tests: port
support::Wsto offrails.v1, addwire_test.rsand the resume/session cases inkernel_pubsub_test.rs, andtests/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:upgradenow 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 newsupports opencode too.- Vendoring: the
rsyncexcludes are anchored (/skills,/hooks,/evals,/brand…), sotools/brand/is kept, and--deleteremoves 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) andkernel::db::drop_idle_test_databases(fortest_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/mailersanswers 301 explicitly (Redirect::permanentis 308).- Magic links:
consumeis one transaction returning user, session and token; first-login user creation usesON CONFLICT DO NOTHING(a unique violation aborts a Postgres transaction; a general rule in models.md);Option<CurrentSession>needsOptionalFromRequestPartsin axum 0.8; token pages sendCache-Control: no-storeandReferrer-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);enqueuedreturns(job::Model, W); per-email rate limits are tested across IPs withAPP_TRUSTED_PROXY+X-Forwarded-For. - Docs:
#[rustfmt::skip]onROUTES; kernel pages use noconfirm()dialogs (data-confirmpattern for app pages); the e2e package arrives with the fir...
Rust off Rails 0.3.0
What's new
- Open source (MIT OR Apache-2.0), with a one-line install (
curl -fsSL https://rustoffrails.org/install.sh | sh) and theoffrailscommand (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: apiand anAPI: none | rest | graphql | bothheader line; REST under/api/v1with bearer tokens and OpenAPI, or GraphQL. - Svelte as a UI library, chosen with a new
UI_LIBRARYheader line. - Realtime channels: one WebSocket per tab, rooms with presence, an opt-in Postgres cluster layer, and faster signals (
NOTIFYon Postgres: 238 ms to 2.1 ms). compose.yamlruns the whole app, and every app with pages gets favicons, social cards,robots.txtand a sitemap from day one.
Breaking changes
FRONTEND_MODE: reactis renamedspa, and the app'sAGENTS.mdheader gainsUI_LIBRARYandAPIlines.- 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.jsonneeds atypecheckscript. - Every mounted route must have a
ROUTESrow (a new test fails otherwise).
Upgrading
Each subsection's App migration list below. The procedure is in upgrade.
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 acurrentsymlink; installs the Claude Code plugin whenclaudeis present; prints the AGENTS.md prompt for Codex and Cursor. Idempotent, no telemetry. See INSTALL.md.offrails:new <name> "<pitch>",doctor,version,update,uninstall.- New apps also get
.cursor/rules/offrails.mdcand.github/copilot-instructions.md, pointing toAGENTS.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:securityskill; hooks for security tripwires, banned crates inCargo.toml, append-only committed migrations, raw.begin(), and nopkill; content-fingerprint drift detection;/offrails:upgradereconciles 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-requestsand HSTS only with an httpsAPP_URL;no-storeon every authenticated response;- production cookies forced to
Secure; HttpOnly; SameSite=Lax.
- the full header matrix, adding
- Session cookie:
__Host-session_tokenin production, and production ignores the plain name. - CI:
- a
securityjob (cargo-deny + gitleaks over the full history); - the Docker job boots the production image and runs the ZAP baseline;
- Dependabot.
- a
- Findings fixed:
- the flash cookie had no
Securein production; - authenticated pages were cacheable;
- panic 500s and timeout 408s had no security headers.
- the flash cookie had no
App migration (reconcile, don't re-create):
- Header middleware. Detect:
src/kernel/security.rs.- Missing: generate it per
guides/security.md#the-header-matrix. Inroutes.rs, replace the per-headerSetResponseHeaderLayers and theCSPconst 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.
- Missing: generate it per
- Session cookie. Detect
PROD_COOKIEinkernel/session.rs. Missing: addname(secure)and makeread(headers, secure)accept onlyname(secure). Updatecookie/clear_cookie, theload_sessioncaller and any WebSocket upgrade that reads the cookie. Tests useksession::name(config.is_production()). Deploying logs everyone out once, because the cookie name changes. Say so to the human. tests/app/headers_test.rs. Add it (one row per response class the app has), plusTestApp::request_head. Production rows use anhttps://APP_URL. Make it fail first if the middleware is off.- Scanner configs. Add
deny.toml(copy the reference insecurity.md; keep any[bans]the app already has), withpublish = falsein 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. - CI. Add the
securityjob and the Docker job's boot + ZAP steps fromguides/testing.md#ci. Lint with actionlint. - Feature specs. Add a
## Securitysection to everyproduct/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/devand/admin,GET /as a JSON index,/robots.txtdisallowing 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.jsonwith a drift test, plus Scalar at/dev/api-docs.
- bearer API tokens (
- GraphQL lives at
POST /graphql: async-graphql, DataLoaders, depth (≥ 13, or introspection breaks) and complexity limits, payloadUserErrors,docs/schema.graphqlwith 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.yamlruns the whole app: the production image plus Postgres with healthchecks, and profilesworkerandmail. Newapp healthsubcommand for container healthchecks.- Day-one brand and meta for every mode with pages:
tools/brand/render.shwritesfavicon.svg,favicon.ico,apple-touch-icon.png,icon-512.pngandog.png;Ctxcarries the title, description and canonical URL;- the layout has Open Graph and Twitter tags;
/robots.txtand/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_formis a kernel dependency (the jobs dashboard's filter links).newadds it again, which corrects the earlier doc-fix pass.
App migration (reconcile, don't re-create):
- Header. If
AGENTS.mdhas noAPI:line, addAPI: noneunderISLANDS:. An app that already serves a public API (tokens,/api/v1) recordsrest, and followsguides/api.mdfor anything it lacks. - compose.yaml. Detect:
grep -q '^ app:' compose.yaml.- If it has only
postgres/db, replace it with the shape inguides/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 healthtosrc/main.rsif missing. - Verify with
docker compose up --build, thendown -v.
- If it has only
- Brand and meta (modes with pages).
- Detect:
static/og.png,static/apple-touch-icon.png, andog:imagein 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 theCtxmeta fields from `guide...
- Detect: