Diff v2 → v3

v2: bot legacy · 2026-07-23T08:15:50Z
v3: bot legacy · 2026-08-13T07:10:22Z
  # Rails Production Playbook
  
  A short, opinionated reference for building serious Rails apps. The **principles**
  are language-agnostic — anyone can lift them — but the **gems and snippets** are
  Rails-first. Default posture: *boring, explicit, deny-by-default, observable.*
  
  > Rule of thumb: reach for a pattern when the pain it removes is real, not
  > hypothetical. Every item below has a "use when" so you don't over-engineer.
  
  ---
  
  ## Architecture & code design
  
  ### Sandi Metz discipline — lean controllers, service objects, enums
  Keep classes small and single-purpose. Controllers only orchestrate: authenticate,
  authorize, call one object, render. Push logic into POROs ("service objects").
  - **Guidelines:** ≤100 lines/class, ≤5 lines/method, ≤4 params, one job per object.
  - **Gems:** plain Ruby first; `interactor` or `dry-monads` if you want a Result type.
  - **Use when:** always for controllers; extract a service the moment an action does
    more than one thing or touches more than one model.
  - Use Rails `enum` for finite states that are *attributes*; use a **state machine**
    (below) when transitions have rules.
  
  ### Service objects + Result types (dry-monads / dry-contracts)
  Make success/failure explicit instead of raising for control flow.
  - **Gems:** `dry-monads` (`Success`/`Failure`, `Do` notation), `dry-validation`
    / `dry-contracts` (typed, composable input validation at the boundary).
  - **Use when:** multi-step operations that can fail in known ways (payments,
    imports, signups). Validate untrusted input with a contract *before* the service.
  ```ruby
  class CreateInvoice
    include Dry::Monads[:result, :do]
    def call(params)
      attrs   = yield InvoiceContract.new.(params).to_monad   # Failure stops the chain
      invoice = yield persist(attrs)
      Success(invoice)
    end
  end
  ```
  
  ### Slice / vertical-feature development
  Organize by **feature**, not by layer. A change should touch one slice
  front-to-back rather than smearing across `app/*`. Enforce boundaries in a modular
  monolith with **Packwerk** (`packs/`), split to services only when a slice needs
  independent scaling or deploys.
  - **Use when:** the app outgrows "one big `app/`" and teams step on each other.
  
  ### EventBus / pub-sub
  Decouple side effects (emails, audit, search reindex) from the core write.
  - **In-process, cheap:** `ActiveSupport::Notifications` or `wisper`.
  - **Durable / event-sourced:** `rails_event_store`.
  - **Use when:** one action fans out to ≥2 unrelated reactions. Keep the *write*
    transactional; emit events `after_commit` so listeners never see rolled-back data.
  
  ### Enums — named values, never magic strings
  Raw strings scattered through code (`"pending"`, `"active"`, `where(status: "shipped")`)
  are typo-prone and have no single source of truth. Centralize the allowed values.
  - **Rails `enum`:** maps a column to named values; gives you scopes
    (`Order.shipped`), predicates (`order.shipped?`), and setters (`order.shipped!`).
    Define the mapping **explicitly** so values are stable — never rely on positional
    integers, and prefer a string-backed column for readability in the DB.
  - **Richer enums (behavior/metadata/i18n per value):** `enumerize`, or a small PORO
    registry / value object when each value carries logic. Translate display labels
    via i18n — don't hardcode human strings either.
  - **Use when:** any attribute drawn from a fixed, known set (status, role, kind,
    priority). One definition, referenced everywhere.
  ```ruby
  class Order < ApplicationRecord
    enum :status, { draft: "draft", paid: "paid", shipped: "shipped" }, default: :draft
    # → Order.paid, order.paid?, order.shipped!, Order.statuses
  end
  ```
  - **Relationship to state machines:** an `enum` lists the *values*; a **state
    machine** governs the *transitions* between them. If moving between values has
    rules, guards, or callbacks (`draft → paid → shipped`, never `draft → shipped`),
    let the state machine own the column — it becomes the single source of truth for
    both the allowed states *and* the legal moves. Plain `enum` is the right tool only
    when any value can follow any other.
  
  ### State machines (AASM)
  Model lifecycles with explicit, guarded transitions and callbacks.
  - **Gems:** `aasm` (attribute-based, ergonomic — status lives on the model);
    `statesman` (Gusto — stores each transition as a row in its own table, giving a
    built-in audit trail and history, at the cost of more setup);
    `state_machines-activerecord` (another attribute-based option).
  - **Pick:** `aasm` for simple lifecycles on the record; `statesman` when you need the
    full transition history / audit of *how* it got to a state.
  - **Use when:** an object moves through statuses with rules (`draft → published →
    archived`), not just a flat enum.
  
  ---
  
  ## Data & persistence
  
  ### IDs: bigint internal, UUID external, friendly_id for URLs
  - **`bigint`** primary keys for internal joins (fast, compact).
  - **`uuid`** (a separate `public_id`) for anything exposed in APIs/URLs — never leak
    sequential counts or enable ID enumeration.
  - **`friendly_id`** for human-readable slugs (`/users/jane-doe`, `/articles/the-title`).
  - **Use when:** any app with a public surface. Add the UUID column from day one.
  
  ### Multi-tenancy: RLS / ActsAsTenant as first class
  Make tenant isolation a property of the data layer, not something each query
  remembers to add.
  - **`acts_as_tenant`:** sets a current tenant and auto-scopes every query.
  - **Postgres RLS (row-level security):** the strongest option — the database itself
    refuses cross-tenant rows even if app code forgets. Belt *and* suspenders.
  - **Use when:** B2B / SaaS with shared tables. Decide *before* writing schema;
    retrofitting tenancy is painful. If you don't need tenancy, don't add it.
  ```sql
  ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
  CREATE POLICY tenant_isolation ON invoices
    USING (account_id = current_setting('app.current_account_id')::bigint);
  ```
  
+ #### Two database roles: `app` (restricted) and `migrator` (owner)
+ RLS is only as strong as the role that connects. **A table's owner, any
+ `SUPERUSER`, and any role with `BYPASSRLS` silently ignore every policy** — so if
+ your app connects as the same role that ran the migrations, RLS is decoration.
+ Split the roles:
+ 
+ - **`myapp_migrator`** — owns the schema, runs migrations, creates policies.
+   Used by `rails db:migrate` / deploy tasks only. Never by the running app.
+ - **`myapp_app`** — owns nothing, `NOSUPERUSER NOBYPASSRLS`, holds only
+   `SELECT/INSERT/UPDATE/DELETE` on tables plus `USAGE` on sequences. This is the
+   role Puma and Sidekiq connect as. Policies actually apply to it.
+ 
+ ```sql
+ CREATE ROLE myapp_migrator LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
+ CREATE ROLE myapp_app      LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS;
+ 
+ -- migrator owns the schema; app only uses it
+ GRANT USAGE ON SCHEMA public TO myapp_app;
+ ALTER DEFAULT PRIVILEGES FOR ROLE myapp_migrator IN SCHEMA public
+   GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO myapp_app;
+ ALTER DEFAULT PRIVILEGES FOR ROLE myapp_migrator IN SCHEMA public
+   GRANT USAGE, SELECT ON SEQUENCES TO myapp_app;
+ 
+ -- belt: FORCE makes the policy apply even to the table owner
+ ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
+ ALTER TABLE invoices FORCE  ROW LEVEL SECURITY;
+ ```
+ Add `FORCE ROW LEVEL SECURITY` anyway — it closes the hole if someone ever points
+ the app at the owning role by mistake.
+ 
+ **Wire it in Rails.** Two URLs, one per role; migrations use the privileged one:
+ ```yaml
+ # config/database.yml
+ default: &default
+   adapter: postgresql
+   url: <%= ENV["DATABASE_URL"] %>            # myapp_app — runtime
+   migrations_paths: db/migrate
+ 
+ production:
+   primary:
+     <<: *default
+   primary_migrator:                           # rails db:migrate uses this
+     <<: *default
+     url: <%= ENV["MIGRATOR_DATABASE_URL"] %>  # myapp_migrator
+     migrations_paths: db/migrate
+     database_tasks: false
+ ```
+ Then set the tenant per request, **transaction-scoped** (`set_config(..., true)`)
+ so a pooled connection can never leak one tenant's id into the next checkout:
+ ```ruby
+ class ApplicationRecord < ActiveRecord::Base
+   def self.with_tenant(account_id)
+     transaction do
+       connection.exec_query(
+         "SELECT set_config('app.current_account_id', $1, true)",
+         "set_tenant", [account_id.to_s]
+       )
+       yield
+     end
+   end
+ end
+ ```
+ 
+ #### Use the restricted role in development too
+ The most common way RLS fails in production is that nobody ever exercised it.
+ Locally everyone connects as their own superuser (`postgres`, or the macOS
+ username Homebrew created), every policy is bypassed, and a query missing its
+ tenant scope looks perfectly fine — until prod, where it returns zero rows or,
+ worse, the policy was never right and prod is the first place it's tested.
+ 
+ - **Dev and test connect as `myapp_app`, same as production.** Create the role in
+   `bin/setup` / a seed task so a fresh clone gets it automatically.
+ - **Only `db:migrate`, `db:prepare`, and `db:schema:load` use `myapp_migrator`** —
+   in every environment, so dev and prod fail the same way.
+ - **Payoff:** a forgotten `with_tenant` blows up on your laptop, in a failing
+   spec, instead of in an incident. Write a spec that connects without a tenant set
+   and asserts the query returns **no rows** — that test is the real proof RLS is on.
+ - **Gotcha:** if dev used to run as owner/superuser, expect a wave of permission
+   errors the first time you switch (missing `GRANT`s on new tables, sequences,
+   extensions). That's the point — those same gaps were latent in prod.
+ 
  ### JSONB for metadata
  Use a `jsonb` column for sparse, schemaless, or fast-evolving attributes —
  *not* as an excuse to avoid real columns for things you query or index heavily.
  - **Rails:** `store_accessor :metadata, :referrer, :utm_source`; GIN-index when you
    query inside it.
  - **Use when:** flexible per-record extras (settings, integration payloads,
    feature config). Promote a key to a real column once you filter/sort on it.
  
  ### Transactions — when & how
  Wrap writes that must succeed or fail **together**.
  - **Do:** group related `INSERT`/`UPDATE`s; use DB constraints as the real guarantee;
    put side effects in `after_commit`, never mid-transaction.
  - **Don't:** make HTTP/network calls inside a transaction (holds locks, can't roll
    back the remote side); don't span a transaction across user think-time.
  - Use advisory locks / `with_lock` for "check-then-act"; pick an isolation level
    deliberately when you have read-modify-write races.
  
  ### Auditing (paper_trail / audited / logidze)
  Track who changed what, when — for compliance, debugging, and undo.
  - **`paper_trail`:** version history with whodunnit, restore previous versions.
  - **`audited`:** lighter change log.
  - **`logidze`:** Postgres-trigger based (no app-layer overhead).
  - **Use when:** regulated data, financial records, or anything users dispute.
  
  ---
  
  ## Security
  
  ### Policies, zero trust, deny-by-default
  Authorization is a first-class layer, not scattered `if current_user.admin?`.
  - **Gems:** `pundit` or `action_policy` (policy objects per resource);
    `rack-attack` (rate limit / block); `brakeman` + `bundler-audit` in CI.
  - **Posture:** **deny by default** — no policy means no access. Authorize every
    action; never trust client-supplied IDs, scopes, or roles. Strong params always.
  - **Use when:** always. Add a default `deny` and opt routes *in*.
  
  ### Secrets vs config — credentials vs ENV vs YAML
  Three buckets, don't mix them:
  - **`.yml`-backed config objects** for non-secret, environment-shaped values:
    URLs, database names, endpoints, timeouts, feature defaults.
    Gems: `config` (`Settings.x.y`) or `anyway_config`.
  - **Rails encrypted credentials** for *secrets*: DB passwords, integration API
    keys, signing keys (`rails credentials:edit`, committed encrypted).
  - **ENV** only for *runtime-start* values that differ per host/boot and aren't
    secret by nature (e.g. which credentials key to load, `RAILS_MAX_THREADS`).
  - **Rule:** if leaking it is a breach → credentials. If it just shapes the
    environment → yml. If the platform injects it at boot → ENV.
  
+ ### Static security analysis — one command, `bin/ci`
+ Security scanning that only runs on someone's laptop when they remember doesn't
+ run. Put every static check behind **one entrypoint** developers and CI both call,
+ so "did it pass?" has a single answer.
+ 
+ Rails 8.1 ships this natively: `bin/ci` driven by a `config/ci.rb` DSL. On older
+ Rails, write `bin/ci` as a shell script that exits non-zero on the first failure —
+ the value is the single entrypoint, not the DSL.
+ ```ruby
+ # config/ci.rb
+ CI.run do
+   step "Setup",     "bin/setup --skip-server"
+   step "Style: Ruby",   "bin/rubocop"
+   step "Security: Gem audit",      "bin/bundler-audit"
+   step "Security: Importmap audit", "bin/importmap audit"
+   step "Security: Brakeman",
+        "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
+   step "Tests: Rails",  "bin/rails test"
+   step "Tests: System", "bin/rails test:system"
+ end
+ ```
+ The GitHub Actions workflow then shrinks to one line — `run: bin/ci` — which means
+ CI can't drift from what you run locally.
+ 
+ **What to put in it:**
+ - **`brakeman`** — Rails-aware SAST: SQL injection, mass assignment, unsafe
+   `render`/`redirect_to`, XSS, command injection, weak crypto. Run with
+   `--exit-on-warn`; triage real findings and check in `config/brakeman.ignore`
+   (with a reason per entry) rather than lowering confidence thresholds.
+ - **`bundler-audit`** — gem dependencies against the Ruby Advisory DB.
+   `bundle audit check --update`.
+ - **`ruby_audit`** — the same for the Ruby and RubyGems versions themselves,
+   which `bundler-audit` doesn't cover.
+ - **`bin/importmap audit`** — JS dependency CVEs when you use importmaps; use
+   `yarn npm audit` / `pnpm audit` if you bundle instead.
+ - **`rubocop`** + **`rubocop-rails`/`-rspec`/`-performance`** — style is not
+   security, but consistent code makes the security diffs readable. Add
+   **`erb_lint`** for template-level escaping issues.
+ - **Secret scanning:** `gitleaks` (or `trufflehog`) over the diff *and* the
+   history — a rotated key still in git history is still leaked. Pair with a
+   pre-commit hook so it never lands in the first place.
+ - **Workflow hardening:** `actionlint` + `zizmor` if you're on GitHub Actions —
+   CI itself is an attack surface (unpinned actions, `pull_request_target`,
+   injectable `${{ }}` expressions).
+ - **Dependabot / Renovate** for the *upgrade* side; the audit steps above are the
+   *gate*. You want both — one opens PRs, the other fails the build.
+ - **`semgrep`** when you need custom org rules ("never call this helper",
+   "no raw SQL outside `app/queries`") that Brakeman doesn't model.
+ 
+ **Rules that keep it honest:**
+ - **Fail the build, don't warn.** A scanner whose output is advisory is ignored
+   within two sprints.
+ - **Every suppression carries a reason and an owner.** Ignore files are fine;
+   anonymous ignore files are technical debt with a security label.
+ - **Run it on the PR, and on a schedule.** New CVEs land against unchanged code —
+   a nightly `bin/ci` run catches what the PR gate structurally can't.
+ - **Keep it fast.** If `bin/ci` takes 20 minutes nobody runs it locally, and the
+   local run is the whole point.
+ - Static analysis finds *classes* of bugs, not your bugs. It does not replace
+   authorization policies, RLS, or a review of the actual threat model.
+ 
  ---
  
  ## Reliability & resilience
  
  ### HTTP errors the RFC way (RFC 9457 `problem+json`)
  Return machine-readable errors with a stable shape, not ad-hoc JSON.
  - **Shape:** `type`, `title`, `status`, `detail`, `instance` (+ `errors`, `trace_id`).
  - **Rails:** `rescue_from` → render `application/problem+json`. No dedicated gem
    needed; a thin renderer is enough.
  - **Use when:** any API with external consumers (incl. your own SPA / agents).
  ```ruby
  rescue_from Pundit::NotAuthorizedError do |e|
    render json: { type: "/errors/forbidden", title: "Forbidden",
                   status: 403, detail: e.message, trace_id: request.request_id },
           status: :forbidden, content_type: "application/problem+json"
  end
  ```
  
  ### Idempotency
  Make retried writes safe — same request, same result, no duplicates.
  - **How:** accept an `Idempotency-Key` header; store key + response; replay on
    repeat; reject body mismatch (`422`). Back it with a unique index.
  - **Gems:** roll your own table, or `sidekiq-unique-jobs` for job-level dedup.
  - **Use when:** payments, order creation, anything a client/network may retry.
  
  ### Circuit breakers
  Stop hammering a failing dependency; fail fast and recover automatically.
  - **Gems:** `stoplight` (general), `semian` (Shopify — for net/http, MySQL, Redis),
    `faraday-retry` for retries with backoff.
  - **Use when:** you call flaky third parties or internal services over the network.
  
  ### Feature flags
  Decouple deploy from release; kill-switch risky paths; gradual rollout.
  - **Gem:** `flipper` (+ `flipper-active_record`, `flipper-ui`).
  - **Use when:** trunk-based dev, canary releases, per-account betas, ops kill-switch.
  
  ### `/health` — real readiness, not just "the web server is up"
  Verify the whole boot: DB reachable, migrations current, Redis/queue up,
  required integrations reachable. Separate **liveness** (am I running?) from
  **readiness** (can I serve traffic?).
  - **Rails 7.1+:** `/up` exists but only proves the process booted — extend it.
  - **Gems:** `okcomputer` or `health_check` for multi-dependency checks.
  - **Use when:** any real deployment — load balancers and orchestrators gate on it.
  
  ### Versioning & releases
  Every deploy should carry a real, traceable version — not just a git SHA in your head.
  If you deploy with **Kamal** (Basecamp's own, very Rails-idiomatic tool), auto-bump a
  semantic version from your Conventional Commits and tag the release **only on a healthy
  deploy**, via `.kamal/hooks/` pre/post-deploy hooks. Full recipe:
  [[kamal-auto-version|Kamal Auto-Versioning]].
  - **Tooling:** `svu` (Conventional Commits → semver: `feat:`→minor, `fix:`→patch,
    `!`/`BREAKING CHANGE:`→major), a `pre-deploy` hook to preview/guard, a `post-deploy`
    hook to `git tag` after success.
  - **Surface it:** expose the version + git SHA on your deep `/health` (above) so a running
    instance self-reports exactly what shipped — invaluable during an incident. (In Rails,
    read it from an ENV/initializer rather than the Go `ldflags` trick in the reference.)
  - **Use when:** any Kamal-deployed app — so every release is tagged in git and traceable.
  
  ---
  
  ## Scalability
  
  - **Vertical:** bigger box first — simplest win until a single node is maxed.
  - **Horizontal:** more app servers behind a balancer; tune **Puma** workers/threads
    and the DB **connection pool** together; move work to background jobs
    (`Sidekiq` / `GoodJob` / `Solid Queue`).
  - **Database:** read replicas + Rails multi-DB (`connects_to`), then sharding only
    when a single primary is the bottleneck. Cache aggressively (`Solid Cache`,
    Russian-doll fragment caching).
  - **Use when:** scale to the *next* bottleneck you can measure — not preemptively.
  
  ---
  
  ## Observability
  
  You can't fix what you can't see. Aim for the three pillars + errors.
  - **Logs:** structured/JSON (`lograge` + custom fields, request id, tenant id).
  - **Metrics:** `yabeda` → Prometheus → Grafana; instrument with
    `ActiveSupport::Notifications`.
  - **Traces:** OpenTelemetry (`opentelemetry-ruby`) across web → job → DB → HTTP.
  - **Errors:** Sentry / Honeybadger with release + user/tenant context.
  - **Use when:** before you need it. Wire request-id + tenant-id through every log
    line so an incident is greppable end-to-end. (Plug your own stack in here.)
  
  ### Correlation IDs — one thread through everything
  A single id that follows **one logical operation** end-to-end: inbound request →
  background jobs → outbound HTTP → emitted events → every log line. It turns "what
  actually happened to this request?" from archaeology into a single grep.
  - **Vs. request id:** a request/trace id is per-hop; a **correlation id** spans the
    whole business operation across processes and async boundaries. Often you carry
    both.
  - **How:** accept it at the edge (`X-Correlation-ID` header) or mint one; stash it in
    `ActiveSupport::CurrentAttributes` so any code can read it without threading it
    through every method; then propagate it deliberately —
    - into **every structured log line** (and your metrics/trace context),
    - echoed back in the response and in `problem+json` (`trace_id`),
    - copied into **background job** arguments/metadata (e.g. a Sidekiq client
      middleware), so async work keeps the same id,
    - forwarded as a header on **outbound** service calls,
    - attached to **emitted events** (see EventBus) so reactions stay traceable.
  - **Gems/Rails:** `ActiveSupport::CurrentAttributes`, `ActionDispatch::RequestId`
    (`X-Request-Id`); a small Sidekiq middleware pair to set/read it on jobs.
  - **Use when:** anything that crosses more than one process, job, or service — i.e.
    every non-trivial system. Pairs naturally with **idempotency** keys and the
    **EventBus**.
  
  ---
  
  ## Testing
  
  ### The suite (rspec, factories, fakes, end-to-end)
  - **`rspec-rails`** as the framework; table-driven where it fits.
  - **`factory_bot`** + **`faker`** for test data; prefer factories over fixtures.
  - **Fakes/stubs for I/O:** `webmock` / `vcr` for HTTP, in-memory fakes for adapters
    — never hit real third parties in tests.
  - **End-to-end user-story tests:** drive real flows (sign up → do the thing → see
    result). `capybara` + `cuprite`, or **Playwright via MCP** for browser-level
-   journeys an agent can run.
+   journeys an agent can run — same pass also yields the QoE numbers (Web Vitals,
+   console errors, page weight), which no request spec will ever give you. Recipe:
+   [QA and QoE Testing with Playwright MCP](https://wiki.joexbayer.dev/wiki/qa-and-qoe-testing-with-playwright-mcp);
+   the case for measuring it: [QA and QoE](https://wiki.joexbayer.dev/wiki/qa-and-qoe).
  - **Use when:** unit-test logic-heavy POROs; integration-test the slice; reserve a
    handful of slow E2E tests for critical revenue paths.
  
  ### Performance & N+1 testing
  Catch slow queries and fan-out in CI, not in production.
  - **Gems:** `prosopite` (N+1 detection — strict, low false positives; pair with
    `pg_query`), `bullet` (alternative), `test-prof` (`let_it_be`, factory profiling),
    `rspec-benchmark` / `derailed_benchmarks` for memory & boot.
  - **Use when:** any list/index endpoint, any view that loops over associations.
    Fail the build on a new N+1.
  
  ---
  
  ## User feedback
  
  Errors and guidance are product, not afterthoughts.
  - **Errors:** human-readable HTML pages for browsers, `problem+json` for APIs
    (above). Tell the user *what to do next*, surface a trace id for support.
  - **Flash / notifications:** `noticed` for multi-channel (in-app, email) notices.
  - **Onboarding / tutorials:** `shepherd.js` / `intro.js` product tours.
  - **Use when:** every user-facing failure and every first-run experience.
  
  ---
  
  ## Async & agentic engineering
  
  - **Background work:** `Sidekiq` / `GoodJob` / `Solid Queue` for anything slow,
    retryable, or fan-out. Jobs should be **idempotent** (see above) and small.
  - **Agentic subagents:** decompose AI work into bounded, single-purpose subagents
    with pre-fetched context; orchestrate sequentially or in parallel waves;
    verify outputs with an independent pass (trust-but-verify). Treat an LLM call
    like any flaky network dependency — wrap it (timeouts, retries, circuit breaker)
    and make the surrounding job idempotent.
  - **Use when:** long-running, parallelizable, or AI-driven work that shouldn't
    block the request cycle.
  
  ---
  
  ## Quick reference
  
  | Concern | Reach for | Use when |
  |---|---|---|
  | Multi-tenancy | `acts_as_tenant` + Postgres RLS | shared-table SaaS |
+ | DB roles | `app` (NOBYPASSRLS) vs `migrator` (owner) | any app using RLS — in dev too |
  | Authorization | `pundit` / `action_policy`, deny-by-default | always |
+ | Static analysis | `bin/ci` → `brakeman`, `bundler-audit`, `gitleaks` | always; fail the build |
  | Secrets | Rails credentials | leaking = breach |
  | Config | `config` / `anyway_config` (yml) | non-secret env shape |
  | Runtime boot | ENV | per-host/per-boot |
  | API errors | RFC 9457 `problem+json` | any external API |
  | Idempotency | `Idempotency-Key` + unique index | retryable writes |
  | Circuit breaker | `stoplight` / `semian` | flaky dependencies |
  | Auditing | `paper_trail` / `audited` / `logidze` | regulated/disputed data |
  | Flexible attrs | `jsonb` + `store_accessor` | sparse/evolving metadata |
  | Feature flags | `flipper` | decouple deploy/release |
  | Enums | Rails `enum` / `enumerize` | fixed value set, no transition rules |
  | State machine | `aasm` / `statesman` | guarded lifecycles (owns the enum column) |
  | Result types | `dry-monads` + `dry-contracts` | fail-able multi-step ops |
  | Events | `ActiveSupport::Notifications` / `rails_event_store` | fan-out side effects |
  | Health | `okcomputer` (deep `/health`) | every deployment |
  | Versioning | `svu` + Kamal pre/post-deploy hooks → git tag | traceable releases (see [[kamal-auto-version\|Kamal Auto-Versioning]]) |
  | N+1 / perf | `prosopite`, `test-prof` | list/index endpoints |
  | Testing | `rspec`, `factory_bot`, `vcr`, Playwright(MCP) | everywhere |
  | Observability | OTel, `yabeda`, `lograge`, Sentry | before you need it |
  | Correlation ID | `X-Correlation-ID` + `CurrentAttributes` | trace one op across processes |
  | Background/agents | `Sidekiq`/`Solid Queue`, bounded subagents | slow/parallel/AI work |
  | IDs | bigint internal · uuid external · `friendly_id` | public surfaces |
  | Scale | vertical first, then horizontal + replicas | at a measured bottleneck |