.offrails/structure.md
Structure & Naming
Every Off Rails app has this exact layout. Don't add top-level directories. Don't move things. Directories marked with a mode exist only in that mode.
myapp/
├── AGENTS.md # from blueprints/AGENTS.md: mode header + pointer to .offrails/
├── CLAUDE.md # one line: "@AGENTS.md"
├── .cursor/rules/offrails.mdc # Cursor: always-applied pointer to AGENTS.md
├── .gitattributes # * text=auto eol=lf (from the template): LF on every OS
├── .github/copilot-instructions.md # Copilot: pointer to AGENTS.md
├── .offrails/ # this framework, vendored, read-only
├── product/ # HUMAN-OWNED. Specs in Markdown. The only thing humans edit
│ ├── README.md # what the app is, who it's for, ## Decisions
│ └── features/*.md # one file per feature (template: blueprints/feature-spec.md)
├── docs/
│ ├── decisions.md # agent-maintained decision log (one dated line per entry)
│ ├── routes.md # generated by `app routes`
│ ├── openapi.json # API: rest | both: the committed OpenAPI document (a test fails when it drifts)
│ └── schema.graphql # API: graphql | both: the committed SDL (a test fails when it drifts)
├── Cargo.toml # workspace root + the app package
├── migration/ # db/migrate: SeaORM migration crate (workspace member)
│ └── src/{lib.rs, m<timestamp>_<verb>_<noun>.rs}
├── .env.example # every env var, with dev defaults. .env is git-ignored
├── compose.yaml # the whole app, production-shaped: app (the Dockerfile's image) + db (Postgres); profiles worker, mail
├── Dockerfile
├── .dockerignore # target/, storage/, .env, .git/, bin/tailwindcss, web/node_modules/, web/dist/
├── config/deploy.yml | fly.toml | … # only the chosen deploy target's file (guides/deployment.md)
├── .github/workflows/ci.yml # fmt, clippy, tests on SQLite AND Postgres, frontend checks, security (cargo-deny, gitleaks), Docker + ZAP baseline
├── .github/dependabot.yml # weekly grouped updates (cargo, github-actions, npm in TS modes)
├── deny.toml # cargo-deny: advisories, licenses, banned crates, sources (security.md)
├── .gitleaks.toml # secret scanning allowlist: build/runtime paths only
├── .zap/rules.tsv # triaged OWASP ZAP baseline rules, each with its reason
├── storage/ # local SQLite DBs + local file uploads (git-ignored)
├── src/
│ ├── main.rs # clap CLI: serve | worker | db … | routes | task … | jobs … | health (the `bin/rails`)
│ ├── lib.rs # pub mod declarations only
│ ├── config.rs # Config struct, parsed from env once
│ ├── state.rs # AppState (Clone) + AppState::new(config, db): db, config, mailer, cache, assets, rate_limit (+ storage, pubsub on first use); select_mailer
│ ├── routes.rs # THE router + middleware + paths:: helpers + ROUTES (the app's table + each feature's) with Access levels (config/routes.rb)
│ ├── routes/ # one file per feature: <feature>.rs = its router(), paths::, ROUTES rows; wired into routes.rs by 4 lines marked `// <feature>`
│ ├── error.rs # AppError + IntoResponse + ResponseFlavor + shape_errors middleware
│ ├── models/ # app/models: mod.rs (Errors, Page, aliases) + one module per table: <singular>/{mod.rs (entity), params.rs, queries.rs}
│ │ # (generators and snippets write the folder; a part's or a hand-written model may be one <singular>.rs)
│ ├── controllers/ # app/controllers: one folder per feature, one file per action (pair)
│ │ ├── application.rs # shared extractors: Ctx impl, HxRequest, AdminAccess, CurrentUser, RequireUser (ApplicationController)
│ │ ├── errors.rs # not_found: the router fallback
│ │ ├── health.rs # GET /up
│ │ ├── dev.rs # development only: /dev index, /dev/routes, /dev/system, test buttons, /dev/mailers → Previews redirect
│ │ ├── migrations.rs # pending(): the dev pending-migrations page + Run button; /up 503 while pending
│ │ ├── posts/ # mod.rs (re-exports), index.rs, show.rs, new.rs (new+create), edit.rs (edit+update), destroy.rs
│ │ ├── pages/ # `g page`: mod.rs + <page>.rs per page
│ │ ├── api/ # spa (and islands' JSON): handlers at /api. With `API: rest|both`: mod.rs (ApiJson, ApiDoc, ErrorBody) + v1/<resource>.rs at /api/v1
│ │ ├── graphql.rs # API: graphql|both: POST /graphql, GraphiQL at /dev/graphiql (development)
│ │ └── root.rs # api mode: GET / (JSON index), GET /robots.txt
│ ├── views/ # mod.rs (render, Ctx), helpers.rs, then what the client receives, one file per resource
│ │ # htmx: Askama template structs (PostsIndex, PostPartial)
│ │ # inertia: #[derive(Serialize, TS)] page props (PostsIndexProps)
│ │ # spa: #[derive(Serialize, TS)] JSON bodies (PostJson, PostsPage)
│ │ # api: #[derive(Serialize, ToSchema)] JSON bodies (PostJson, PostBody, PostsPage + PageInfo)
│ ├── graphql/ # API: graphql|both: mod.rs (schema, limits, Viewer, gql_error), query.rs, mutation.rs, types.rs, loaders.rs
│ ├── jobs/ # app/jobs: one file per worker + mod.rs (Worker trait, dispatch) + recurring.rs
│ ├── mailers/ # app/mailers: mod.rs (deliver_later, deliver_later_until), previews.rs (PREVIEWS), one file per mailer
│ ├── channels/ # app/channels: mod.rs (GET /streams/{stream} + authorize_stream; GET /socket, AppRoom, impl Channels) + one authorizer per resource + <topic>_room.rs per room
│ ├── policies/ # authorization: mod.rs (authorize) + <plural>.rs (can_* fns + truth-table tests)
│ ├── tasks/ # lib/tasks: `app task <name>`; seed.rs is db/seeds.rb
│ └── kernel/ # framework runtime, generated from guide specs (see stack.md#the-kernel-code-you-generate-instead-of-depending-on)
│ ├── db.rs, flash.rs, rate_limit.rs, assets.rs, mailer.rs, cache.rs # generated by `new`
│ ├── ui.rs # shared chrome for kernel pages (Chrome, KernelFlash, no_store, page)
│ ├── jobs/ # mod.rs (tables, Outcome, JobInfo, JobBuilder, Unique), executor.rs (producers, claim, record, drain),
│ │ # plugins.rs (Heartbeat, Stager, Lifeline, Pruner, Cron), admin.rs (shared ops), web.rs (/admin/jobs)
│ ├── mailbox.rs # Local mailer storage + the dev mailbox UI (/dev/mailbox, Previews tab, JSON)
│ └── session.rs, return_to.rs (authentication), pubsub.rs (channel), hub.rs + channels.rs + cluster.rs (first room), storage.rs (attachment), inertia.rs # added by the generator/mode that needs them
├── templates/ # Askama HTML (app/views)
│ ├── layouts/application.html # htmx: main layout. inertia: the root HTML shell
│ ├── errors/ # htmx: 404.html, 403.html, 500.html (standalone, no layout, no ctx)
│ ├── shared/ # htmx: _flash.html, _errors.html, _nav.html, _dev_mailbox_hint.html (authentication)
│ ├── posts/ # htmx: index.html, show.html, new.html, edit.html, _form.html, _post.html
│ ├── pages/ # htmx: <page>.html per `g page`
│ ├── mailers/ # all modes: layout.html (THE mail layout) + <mailer>/<action>.html and .txt
│ └── kernel/ # all modes: layout.html, jobs/ (dashboard), mailbox/, dev/ (Askama + htmx, framework-owned look)
├── assets/app.css # htmx: Tailwind input
├── brand.toml # modes with pages: the look (colors, fonts, radius, logo); `cargo offrails brand` renders it
├── assets/brand.css # generated from brand.toml: the Tailwind theme (never edit)
├── assets/brand/og.svg # generated: the social card source
├── static/ # public/: served at /static (Vite build output is served at /assets from web/dist)
│ ├── app.css # htmx: Tailwind output (built, git-ignored)
│ ├── favicon.svg, favicon.ico, apple-touch-icon.png, icon-512.png, og.png # modes with pages, from brand.toml (`cargo offrails brand`; `new` runs it)
│ ├── kernel/admin.css # all modes: plain CSS for /admin/jobs, /dev, /dev/mailbox
│ ├── kernel/admin.js # all modes: reload kernel pages restored from the back/forward cache
│ └── vendor/htmx-<ver>.min.js # all modes (the dashboard and /dev use it)
├── web/ # inertia | spa | islands: the Vite + TS app (UI_LIBRARY react: .tsx, svelte: .svelte)
│ ├── package.json, pnpm-lock.yaml, vite.config.ts, tsconfig.json, biome.json, playwright.config.ts
│ ├── e2e/ # inertia | spa: Playwright specs for the critical flows
│ └── src/
│ ├── main.tsx # entry: createInertiaApp (inertia) / router + QueryClient (spa). Svelte: main.ts; spa = SvelteKit (src/routes/, src/lib/; see frontend-svelte.md)
│ ├── types/ # GENERATED by ts-rs. Never edit. Committed (Docker/CI build web/ before Rust tests)
│ ├── inertia.d.ts # inertia: types usePage() from the generated SharedProps/Flash
│ ├── test-setup.ts # jest-dom matchers + afterEach(cleanup) (Svelte: jest-dom only; svelteTesting() cleans up)
│ ├── paths.ts # mirror of Rust paths:: helpers (inertia/spa). Keep in sync
│ ├── pages/<resource>/ # inertia: Index.tsx, Show.tsx, New.tsx, Edit.tsx (or .svelte). ONLY pages (no forms, no tests)
│ ├── pages/errors/Show.tsx # inertia: 403/404/500 on Inertia visits
│ ├── routes/<resource>/ # spa: same file names, React Router pages (Svelte: SvelteKit +page.svelte files)
│ ├── api/ # spa: client.ts (the ONLY fetch caller) + <resource>.ts hooks (Svelte: src/lib/api/)
│ ├── components/ # shared UI (Layout, Flash…); inertia forms: components/<resource>/<Model>Form.tsx (+ .test.tsx; Svelte: .svelte + .svelte.test.ts)
│ └── islands/ # islands: one file per island
├── .cargo/config.toml # [alias] offrails (cargo offrails add-part / list-parts, from the template); inertia/spa also: [env] TS_RS_EXPORT_DIR = { value = "web/src/types", relative = true }, TS_RS_LARGE_INT = "number"
├── .cargo/mutants.toml # cargo-mutants: equivalent-mutant exclusions, each with its reason (test-standards §5)
├── tests/app/ # ONE integration test binary (fast linking)
│ ├── main.rs # mod declarations only (`#[allow(dead_code)] mod support;`)
│ ├── support.rs # TestApp, TestResponse
│ ├── factories.rs # + factories/<singular>.rs per model (`mod x; pub use x::*;`, marked)
│ ├── smoke_test.rs # generated by `new`
│ ├── security_test.rs # access matrix over ROUTES, CSRF (test-standards.md §3)
│ ├── headers_test.rs # the header matrix per response class, dev + prod (security.md)
│ ├── kernel_jobs_test.rs # generated by `new`: the queue (guides/jobs.md#testing-obantesting-equivalents)
│ ├── kernel_web_test.rs # generated by `new`: /admin/jobs, /dev/mailbox, /dev
│ ├── accounts_test.rs # authentication generator
│ ├── posts/ # a generated resource: mod.rs (submit helper), read_test.rs, write_test.rs, access_test.rs
│ └── reports_test.rs # a generated controller (one file); pages/<page>_test.rs per `g page`
├── tests/e2e/ # htmx: dev-only real-browser smoke, created with the first critical flow (authentication or the first scaffold), not by `new` (package.json with @playwright/test, playwright.config.ts, smoke.spec.ts; realtime.spec.ts with channels)
└── bin/
├── setup # first-run: tools, .env, CSS or pnpm install, db setup
├── setup-tailwind # htmx/islands: pinned + checksum-verified Tailwind download
├── dev # migrate, then run everything for development
├── docker-entrypoint # migrate (unless SKIP_MIGRATIONS=true), then exec
└── tailwindcss # htmx: standalone binary (git-ignored, fetched by bin/setup-tailwind)Naming conventions
| Thing | Convention | Example |
|---|---|---|
| Table | plural snake_case | blog_posts |
| Model module / type | singular module, Model inside, alias in models/mod.rs | models/blog_post.rs, pub type BlogPost = blog_post::Model; |
| Params struct | <Model>Params | BlogPostParams (untrusted input, all String/Option) |
| Validated data | New<Model> | NewBlogPost |
| Controller file | plural | controllers/blog_posts.rs |
| Handler fns | the 7 Rails actions | index show new create edit update destroy |
| Route path | plural kebab-case | /blog-posts/{id}/edit, API: /api/blog-posts/{id} |
| Template dir (htmx) | plural snake_case | templates/blog_posts/ |
| Partial (htmx) | leading underscore | templates/blog_posts/_form.html |
| View struct (htmx) | <Plural><Action> / <Model>Partial | BlogPostsIndex, BlogPostPartial |
| Props (inertia) | <Plural><Action>Props | BlogPostsIndexProps |
| Inertia component | "<plural_snake>/<Action>" ↔ web/src/pages/<plural_snake>/<Action>.tsx (Svelte: .svelte) | "blog_posts/Index" |
| JSON body (spa) | <Model>Json, <Plural>Page | BlogPostJson |
| JSON field names | snake_case everywhere (no serde renames), so TS matches Rust matches DB | published_at |
| Foreign key | <singular>_id (i64) | author_id |
| Timestamps | created_at, updated_at (DateTimeUtc, set in Rust) | |
| Booleans | prefer a nullable timestamp (published_at) over is_published | |
| Job struct | <Verb><Noun>Job | SendWelcomeEmailJob |
| Mailer fn | <mailer>::<action> | user_mailer::welcome |
| Migration | m<YYYYMMDD_HHMMSS>_<verb>_<noun>.rs (via cargo offrails g migration) | m20261012_120000_create_blog_posts.rs |
| Env vars | SCREAMING_SNAKE; app-specific ones prefixed APP_ | DATABASE_URL, APP_URL |
Module rules
lib.rsis the crate.main.rsonly parses CLI and calls into the lib, which keeps everything testable.- Controllers may call models, views, jobs, mailers and policies.
- Models call only the database, other models, and
jobs::enqueue. A model never imports controllers or views. - Views are data plus templates. No database access in views, ever (that's how N+1 queries happen).
- Jobs and mailers call models.
- The kernel depends only on the app's plumbing:
error::AppError,config(types andAPP_NAME),state::AppState(kernel routers and extractors take it viaState/FromRef),models::Errorsand other kernel modules. It never imports feature code: models other thanErrors, controllers, views, policies, jobs or mailers. App-specific wiring (the job dispatcher, worker config, recurring schedule) lives insrc/jobs/and is handed to the kernel as values orfnpointers (DispatchFn,worker_config,&[Recurring]). - Business processes touching more than two models go in the model that "owns" the process (
models/order.rs::checkout). There is noservices/directory. If no model owns it, create a module for the process noun (models/signup.rs).