.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

ThingConventionExample
Tableplural snake_caseblog_posts
Model module / typesingular module, Model inside, alias in models/mod.rsmodels/blog_post.rs, pub type BlogPost = blog_post::Model;
Params struct<Model>ParamsBlogPostParams (untrusted input, all String/Option)
Validated dataNew<Model>NewBlogPost
Controller filepluralcontrollers/blog_posts.rs
Handler fnsthe 7 Rails actionsindex show new create edit update destroy
Route pathplural kebab-case/blog-posts/{id}/edit, API: /api/blog-posts/{id}
Template dir (htmx)plural snake_casetemplates/blog_posts/
Partial (htmx)leading underscoretemplates/blog_posts/_form.html
View struct (htmx)<Plural><Action> / <Model>PartialBlogPostsIndex, BlogPostPartial
Props (inertia)<Plural><Action>PropsBlogPostsIndexProps
Inertia component"<plural_snake>/<Action>" ↔ web/src/pages/<plural_snake>/<Action>.tsx (Svelte: .svelte)"blog_posts/Index"
JSON body (spa)<Model>Json, <Plural>PageBlogPostJson
JSON field namessnake_case everywhere (no serde renames), so TS matches Rust matches DBpublished_at
Foreign key<singular>_id (i64)author_id
Timestampscreated_at, updated_at (DateTimeUtc, set in Rust)
Booleansprefer a nullable timestamp (published_at) over is_published
Job struct<Verb><Noun>JobSendWelcomeEmailJob
Mailer fn<mailer>::<action>user_mailer::welcome
Migrationm<YYYYMMDD_HHMMSS>_<verb>_<noun>.rs (via cargo offrails g migration)m20261012_120000_create_blog_posts.rs
Env varsSCREAMING_SNAKE; app-specific ones prefixed APP_DATABASE_URL, APP_URL

Module rules

  • lib.rs is the crate. main.rs only parses CLI and calls into the lib, which keeps everything testable.
  • Controllers may call models, views, jobs, mailers and policies.
  • Models call only the database, other models, and jobs::enqueue. A model never imports controllers or views.
  • Views are data plus templates. No database access in views, ever (that's how N+1 queries happen).
  • Jobs and mailers call models.
  • The kernel depends only on the app's plumbing: error::AppError, config (types and APP_NAME), state::AppState (kernel routers and extractors take it via State/FromRef), models::Errors and other kernel modules. It never imports feature code: models other than Errors, controllers, views, policies, jobs or mailers. App-specific wiring (the job dispatcher, worker config, recurring schedule) lives in src/jobs/ and is handed to the kernel as values or fn pointers (DispatchFn, worker_config, &[Recurring]).
  • Business processes touching more than two models go in the model that "owns" the process (models/order.rs::checkout). There is no services/ directory. If no model owns it, create a module for the process noun (models/signup.rs).