Roundhouse does not implement Rails. It recognizes the Rails an app uses — the conventions, the DSL, the helpers — and lowers each recognized form to target-neutral IR that every emitter consumes. So "is X supported?" has two parts: does the analyzer recognize X, and does each target have a lowering and a runtime for it. This page is the feature-level answer as of this snapshot; the per-app answer is the tools:
roundhouse check --continue(check.md) — the survey report lists every construct the analyzer did not recognize in your app, and the gem census lists every gem it does not model.- the MCP
wont_lowertool (mcp.md) — for a named target, the constructs in your app that have no lowering to it.
Those are authoritative and current; this page is the map.
Two tiers
Coverage is proven by apps, not by feature lists. Two apps define the tiers.
The blog (fixtures/real-blog, the Rails 8 scaffold with articles,
comments, nested routes, validations, Turbo Streams, Action Cable,
Tailwind, JSON endpoints) is what every server target passes the
DOM-equivalence gate against on every push. What the blog uses is
supported everywhere.
Campfire (Basecamp's chat product — file attachments with image
variants, rich text, web push, bots and webhooks, full-text search,
signed cookies and sessions, Current, fragment caching, rate
limiting, ~70 routes) is what the Ruby shape — running on CRuby,
and compiled by Spinel — passes the same gate against,
along with Campfire's own test suite and its cable broadcasts. What
Campfire uses beyond the blog is supported on those two lanes, and
reaches the others as their emitters and runtimes catch up.
Active Record
| Blog tier (all targets) | Campfire tier (ruby, spinel) | |
|---|---|---|
| Attributes | From db/schema.rb (migrations as fallback), typed per column; id, timestamps, defaults, nullability | + enum, serialize/has_json columns, has_secure_token, has_secure_password (real bcrypt) |
| Associations | belongs_to, has_many (with dependent:), has_one; association readers, builders, <assoc>_ids | + has_many :through, polymorphic, touch:, has_one_attached/has_many_attached, has_rich_text |
| Validations | presence, absence, length (min/max), numericality (bounds, only_integer), format, inclusion, uniqueness; errors, full_messages, valid? | + custom validate methods, conditional if:/unless: |
| Callbacks | after_create_commit and the Turbo broadcasts_to family | + before_save/after_save, before_destroy, after_touch, STI-aware callback inheritance |
| Queries | where (hash and string), order, limit, find, find_by, first/last, count, exists?, includes, pluck, named scopes — folded to SQL by the Arel builder | + joins, left_joins, group/count, select, distinct, or, none, find_or_create_by, insert_all, update_all, in_batches, sum; a live Relation for chains the builder can't fold |
| Persistence | create, save, update, destroy, new/build | + destroy!, increment!, update_columns, transactions, dirty tracking predicates |
| STI | — | type column dispatch, subclass scopes, is_a? on records |
SQL identifier boundary
Compiler-generated SQLite DDL, indexes, seeds and Arel/model CRUD quote
physical identifiers without renaming them, including keywords, spaces,
hyphens and embedded quotes. Emitted models keep table_name raw and
provide the SQL spelling separately to Relation; ordinary filter/order
chains on those models use it too. A dot in one physical name stays part
of that name, not a new schema-qualified ingest feature. Quoting metadata
does not widen the Ruby table-name declarations ingest can interpret.
This is not complete quoting of every SQL path. Association JOIN strings,
nested dynamic qualifiers/column names and native adapter fallbacks remain
gaps: accepted source can still generate invalid SQL there, without a new
unsupported diagnostic. The compiler SQL dialect is SQLite, not
PostgreSQL/MySQL. tests/sql_identifiers.rs executes synthetic SQLite and
emitted CRuby cases; its other language checks prove DDL string preservation,
including literal interpolation markers, not whole-project execution on
every target. Native hand-written models still need SQL-ready identifiers;
the Base metadata fallback does not dynamically quote arbitrary names.
The runtime typing ledger records one additional unresolved self-send in
the isolated RBS probe and three additional gradual reads for raw
RecordNotFound messages through Relation's existing untyped model handle.
The strict fully-typed runtime gate and diagnostics are unchanged; this does
not add generic class-object/Relation support to strict targets.
Action Controller
| Blog tier | Campfire tier | |
|---|---|---|
| Actions | The seven RESTful actions and any other; implicit render | + head, send_file, rescue_from, rate_limit (to:/within:/by:/with:/only:/except:, counted in the app's cache as in Rails) |
| Filters | before_action with only:/except:, ivar flow into views | + around_action, after_action, if:/unless: guards (symbol and lambda), skip_before_action, filters from concerns |
| Params | params.expect, params.require(...).permit(...), params[:id]; typed by the schema they're assigned to | + nested permits, arrays, params.merge, indifferent access |
| Responses | render (template, partial, json:, status:), redirect_to (record, path, status:), respond_to with format.html/format.json, flash and flash.now | + expires_in, stale?/fresh_when (answered as always fresh — a deliberate divergence), cookies and cookies.signed/.permanent, session, helper_method, layout |
| Concerns | included modules with included do filter blocks | + class_methods, concern-defined actions and helpers |
| Auth | — | Current attributes, authenticate_by, signed/global ids, has_secure_password sessions |
Action View
| Blog tier | Campfire tier | |
|---|---|---|
| Templates | ERB; partials (render "form", render @articles, render partial: with locals and collections); layouts; content_for/yield | + HAML is read by the analyzer; emit is ERB-only today |
| Helpers | link_to, button_to, form_with and the form builder (label, text_field, text_area, submit, hidden_field), dom_id, pluralize, truncate, l/t with the app's locale files, csrf_meta_tags, stylesheet_link_tag, javascript_importmap_tags, turbo_stream_from, cache | + image_tag/asset_path with digests, tag.* builders, content_tag, sanitize (safe-list sanitizer port), turbo_frame_tag, the rest of the form builder (select, check_box, radio_button, button, url_field, email_field, password_field, file_field, rich_text_area, fields_for), hidden_field_tag, time_ago_in_words, number_to_human, url_for |
| JSON | jbuilder views (json.extract!, json.array!, partials) | + as_json/to_json shapes on records and plain objects |
| Turbo | Turbo Streams broadcast on model commit, turbo_stream responses, frames | + custom stream targets and broadcast_*_later |
The rest of the framework
| Component | Status |
|---|---|
| Action Cable | Every server target: /cable, turbo_stream_from subscriptions, model broadcasts. Campfire tier adds application channels with subscribed/unsubscribed, stream_for, and presence. |
| Active Job | Campfire tier: perform_later runs on an in-process queue in the app; ActiveJob::TestHelper assertions in the tests. No external queue adapter. |
| Active Storage | Campfire tier: blobs and attachments, the disk service, the engine's routes (redirect and representation), variants via libvips on Spinel. No cloud services. |
| Action Text | Campfire tier: has_rich_text, the safe-list sanitizer, attachment rendering. |
| Action Mailer | Ruby tier: mailer classes, mail(...), deliver_now/deliver_later — delivery appends to ActionMailer::Base.deliveries (Rails' :test method, which the emitted tests assert against). No SMTP. |
| Routing | resources/resource (nested, only:/except:, member/collection), namespace/scope, root, get/post/…, constraints, format suffixes, Active Storage's mounted engine. Not: concern, direct (a custom URL helper with an arbitrary body — dropped), mount of any other engine, Devise's/Doorkeeper's DSL. |
| Configuration | config.x.*, initializers that define constants or mix modules into models, Rails.application.config reads, the app's inflections. Not: Rails.application.credentials. |
| Caching | Fragment caching (cache in views, keyed by record) and Rails.cache.fetch, in-process. |
| Gems | The census names what is modeled. Modeled today: bcrypt, image_processing/ruby-vips (Spinel), rqrcode, useragent, web-push, net-http-persistent, concurrent-ruby's thread pool, importmap-rails, turbo-rails, stimulus-rails, tailwindcss-rails, jbuilder, propshaft. Everything else in a Gemfile is either infrastructure (never enters the analysis) or unknown. |
What is not lowered, anywhere
The analyzer is whole-program and static. Whatever it cannot see through at compile time it cannot type, and whatever it cannot type it does not emit:
- Metaprogramming that constructs names at runtime:
sendandpublic_sendwith a non-literal method name,define_method,method_missing,instance_variable_get/set,const_get,evalin any form,Class.new, andobj.extend Modon one live object (the site — or in a test, the whole test — becomes a raise that names the construct, so the rest of the file still compiles). - Reopening the framework: monkey-patches of Rails or core classes
from initializers (
Module#prependinto a framework class is recorded and skipped on Spinel),alias_methodinsideclass << self. - Dynamic loading:
requireof a file computed at runtime,autoloadof things outside the app's conventions. - Anything reached only through an unknown gem's DSL — the census tells you which.
- Ruby, not Rails: refinements,
ObjectSpace,Fiber/Threadused directly by the app,binding,Methodobjects.
Each of these is reported by name and location in the survey report,
so the answer for a given app is a list, not a guess. That includes a
class-body macro in a controller that roundhouse does not recognize
(Lobsters' caches_page, say): it is listed as a survey gap rather
than dropped in silence, because its effect — a guard, a filter, a
header — would otherwise vanish from the output with no trace.
Deliberate divergences
Some Rails behaviors are reproduced differently on purpose, because
the Rails behavior depends on a runtime facility the targets don't
have or because it is an accident of implementation. Each is a
decision, recorded with its reasoning in the architecture docs:
docs/pipeline/runtime.md §Deliberate divergences from Rails.
The ones a user is most likely to meet: an unsaved record's id is
0, not nil; attribute writers do not type-cast (the column type
is enforced at the boundary instead); conditional GET always answers
fresh; a Turbo stream name is signed only on the Ruby-family targets
(elsewhere it carries an --unsigned placeholder); the query cache
replays small results only; increment! is a read-modify-write.
Anything not in that section that differs from Rails is a bug, and the compare oracle is how to demonstrate it.
Security posture
CSRF is verified on the ruby family where the app declares it —
the CRuby and Spinel lanes, which is where Campfire deploys.
protect_from_forgery with: :exception runs as the before_action
Rails registers, at the same place in the chain, with its only: /
except: / if: / unless:; skip_forgery_protection removes it.
A non-GET request must carry the session's token in the
authenticity_token param or the X-CSRF-Token header, and a present
Origin must name the request's own host; otherwise the answer is
Rails' 422. The emitted test harness
turns the check off, as a generated config/environments/test.rb
does.
What differs from Rails, and why:
- Tokens are not masked. Rails hands out a per-render masked token (a BREACH mitigation); the emit issues the session token itself.
- The Origin check compares hosts, not schemes. Rails compares
request.base_url. The ruby family now readsX-Forwarded-Proto(absolute URLs behind a TLS proxy are https, as in Rails), butassume_sslis not modeled, so the check still compares hosts only. - The session and flash cookies are signed, not encrypted. Rails'
cookie store encrypts the session and keeps the flash inside it; the
ruby family signs the session and gives each flash message
(
flash_notice,flash_alert) a signed cookie of its own — the same HMACcookies.signeduses, keyed fromSECRET_KEY_BASE. A client can read them but not forge them: a cookie that does not verify reads as an empty session or no message. The session payload is not Rails' JSON either, so a migration from Rails starts every session afresh; Campfire's login rides its own signed cookie and carries over. - An unset
SECRET_KEY_BASEis generated, not fatal. Rails development keeps a generated key intmp/local_secret.txt, and production refuses to boot without one. The ruby family generates the key on first boot and keeps it instorage/secret_key_base(mode 0600), the directory a deployment already persists for its database. Set the variable to share one key across instances, or to carry a key over from a Rails deployment. - Rails' implicit default is not applied. Under
load_defaults5.2+, Rails protects everyActionController::Basecontroller even when the app never writes the macro. Here only a writtenprotect_from_forgery with: :exceptionis enforced: an app that relies on the default (the blog, the Rails tutorial) is not protected on any lane. The default would put the check into every target's emit, and the strict targets have no token to check. with: :null_session/:reset_sessionandprepend:are not modeled. Such a macro is reported as a gap and not enforced.- Action Cable's Origin check uses Rails' defaults only. On the
ruby family, a
/cablehandshake must carry anOriginnaming the request's own host (compared by host, as above), or in development anylocalhostport. Otherwise the answer is Rails' 404 and the app'sconnectnever runs; a handshake with noOriginis refused, as in Rails. An app's ownconfig.action_cable.allowed_request_originsanddisable_request_forgery_protectionare not read. The strict targets' sockets check no Origin.
The strict targets issue no token (form_authenticity_token is empty
there) and check none: an app emitted for them is not protected
against cross-site forgery. Put an unprotected app behind something
you trust.
This page is docs/guide/rails-coverage.md in the repository; edits are welcome there.