Migrating from Rails

zigapagos migrate <rails-app> --from rails does two jobs, and which one you get is decided by a single flag.

Without --target it is discovery. It recovers what a Rails app’s routes, controllers, and views actually do, classifies each route with an honest, evidence-gated verdict, resolves each certain route’s Rails helper name and each controller’s declared layout, and parses every route-reachable ERB template into a closed vocabulary of fragments — surfacing anything a converter needs a human decision on as a findings[] entry. That is written as a human report (MIGRATION.md) and a versioned JSON manifest (MIGRATION.manifest.json), and nothing else is produced.

With --target DIR it also converts (issue #167 Stage 2). Every route whose whole template graph has a defined conversion becomes a real content/<url>/index.smd page plus the .shtml layouts it extends; deterministic assets are copied; a zigapagos.ziggy, a build.sh and the rest of a buildable project are written; and DIR/MIGRATION.handoff.json records, per route, what it actually became. A route the converter cannot finish on its own is reported open with the finding ids you have to answer, the run exits 3, and you answer them in DIR/MIGRATION.decisions.json and run the same command again. When every user-facing route is accounted for, the handoff says "complete": true and the run exits 0. Sections §13 through §17 are that half of the tool.

With --backend FILE it also binds (issue #167 Stage 3). FILE is the ZigBase OpenAPI document zigbase openapi writes. Its operations become the choices a RAILS_BACKEND_ENDPOINT finding offers; an answered finding turns the Rails form, the mutating link or the auth journey it names into a generated island that calls that operation through @zigbase/client; and the route’s endpoint is recorded in the handoff. §18 is that half, and without the flag those findings offer nothing but retain/blocked — a backend route can be acknowledged but not bound.

Interactive answers also produce artifacts (issue #167 Stage 4). Portable Stimulus elements, Turbo frames, React roots, and record-backed ivar regions become islands; source-less frames can remain inline and reviewed Stimulus/JS-entry code can be dropped. The source gates, exact generated bytes and deliberate limits are in §19.

Completed targets also carry replay evidence (issue #167 Stage 5). parity[] correlates seven typed page/asset/auth/mutation expectation shapes, and two fixed target-side runners replay them against stock ZigBase and system Chrome. The support matrix, enforcement boundary, and known oracle limits are in §20.

This is a deterministic reference for what the tool does and does not claim, written for an agent or a human driving it unattended — not a tutorial. If you haven’t already, read astro-to-zigapagos.md first (this repository’s docs/migration/ directory, or the published docs site — not shipped alongside this file when it is installed standalone as part of the zigapagos-rails-migration skill): this document follows the same house register (mapping tables, exact command output, explicit gaps).

zigapagos migrate path/to/rails-app -o MIGRATION.md
zigapagos migrate path/to/rails-app --from rails -o MIGRATION.md
zigapagos migrate path/to/rails-app --from rails --target path/to/new-site
zigapagos migrate path/to/rails-app --from rails --target DIR --decisions answers.json
zigapagos migrate path/to/rails-app --from rails --target DIR --backend openapi.json
zigapagos migrate path/to/rails-app --strict

Detection is automatic from conventional Rails evidence (Gemfile, config/application.rb, app/views, …); pass --from rails explicitly in a monorepo or when detection is ambiguous. --from rails on a tree with no Rails evidence is fatal — it does not write a confident, empty report.

--scaffold, --copy-assets, and --convert-content are still rejected for Rails: they are the React/Markdown ports, and the Rails conversion is driven by --target alone.

Source files are read only. Route recovery is a static AST walk of config/routes.rb through a Ruby/Prism sidecar — the app is never booted, no initializer runs, no database connection is opened.

1. What gets written

Two artifacts on a plain -o run, both regenerated every time (plain overwrite, not the .new/.new.2 versioning --scaffold/--copy-assets use elsewhere — this output is not hand-edited so there is nothing to preserve):

FileWhat it is
MIGRATION.md (or -o PATH)A human-readable rendering: inventory counts, routes with their classification and reason, blockers, findings — and, on a --target run, a ## Handoff section counting the six statuses.
<same stem>.manifest.json beside it (e.g. MIGRATION.manifest.json)The zigapagos.rails-presentation/1 manifest — the machine-readable contract. MIGRATION.md is a rendering of the manifest’s data, not an independent source of truth; when the two could be read to disagree, the manifest is authoritative.

A --target DIR run writes both of those into DIR, plus DIR/MIGRATION.handoff.json (§16) and the converted project itself (§13). Given the same app and the same decisions input, the manifest is byte-identical between a -o run and a --target run (tests/migrate/rails.sh pins exactly that): it is discovery’s verdict and knows nothing about the conversion. Only MIGRATION.md differs, by the ## Handoff section appended to it.

The manifest’s shape is described by contract/rails-presentation.v1.schema.json (this repository’s root; not shipped alongside this file in the standalone skill install) — a JSON Schema generated from src/cli/rails/manifest.zig‘s own Zig types (src/cli/rails/schema_gen.zig), not hand-maintained. Field order in every manifest object matches the schema’s declared property order, and that order is part of the wire contract: it is not safe to assume alphabetical or arbitrary key ordering when writing a consumer.

The manifest also carries a top-level findings[] array — the last key, after blockers[] — one entry per per-fragment or per-declaration question the converter needs an operator to answer. See §9 through §12 below for what a finding means, its id format, and the closed vocabulary it is derived from, and §15 for how you answer one.

2. The six classifications

Every recovered route gets exactly one classification, decided by a fixed, first-match-wins rule chain (src/cli/rails/classify.zig). The order is load-bearing, not stylistic: content is the only value that asserts something positive — “this page is safe to treat as static.” Every other value is a deferral, a handoff, or a narrower claim:

ValueWhat it actually asserts
contentThe route’s view (and its resolved layout and every partial it renders, transitively) show no request-time state and no interactivity marker, and a controller action was actually recovered to confirm it. The one positive claim discovery makes.
islandThe view (or a partial/layout it renders) has a Stimulus data-controller attribute or a mounted component root. A narrower, still-positive claim — “this page has client-side interactivity,” not “this page is static.”
backendEither the verb isn’t GET/HEAD, or no view template exists and (the action renders JSON, or — when controller evidence is trustworthy — no action was recovered at all). “The action renders JSON” is not an independent trigger on its own: a dual-format `respond_to {
redirectThe controller action’s body is only a redirect_to call. A handoff to whatever routing scheme the target site uses.
unresolvedDiscovery could not reach a verdict safely. See below — this is the value that obliges a human to look.
spaNever assigned. See below.

classify.Class is checked first-match against classify.zig‘s own rule chain, in this exact order — numbered here exactly as classify.zig‘s own Rule N: comments number them, so a reader can jump straight to the source: Rule 1, non-GET/HEAD verb → backend; Rule 2, no view template, with either a JSON-rendering action or (when controller evidence is trustworthy) no action at all → backend, otherwise (when controller evidence degraded) → unresolved; Rule 3, a redirect-only action → redirect; an unlabeled guard between rules 3 and 4 — no view left to classify at all → unresolved; Rule 4, an unsupported template engine (anything but ERB — Haml, Slim, Jbuilder, Builder, or unidentified) → unresolved; Rule 5, request-time state read by the view, its resolved layout, or a rendered partial → unresolved; Rule 6, a Stimulus controller or component-root marker → island; Rule 7 — the last resort, reachable only once every rule above failed to fire, and only when a controller action was actually recovered — → content. That is eight first-match checks total (seven of them the code’s own numbered Rule 1Rule 7, plus the one unlabeled view-missing guard), not seven — an earlier version of this paragraph renumbered them sequentially 1–8 instead of quoting the code’s own labels, which drifted the doc’s numbering away from classify.zig’s the moment a reader tried to match the two side by side. A static-looking view with no recovered action does not reach content; it falls to unresolved instead (“view looks static but no controller action was recovered to confirm it”), because an absence of counter-evidence is not proof.

unresolved — what it obliges a human to do

Every route carries the exact string its classification rule returned, in both artifacts: the manifest’s routes[].reason field, and — right after the classification, in parentheses — the corresponding line in MIGRATION.md (e.g. - `GET /about` — unresolved (no view template to classify)). This matters most for unresolved: candidates[] is empty by design for that class (classify.zig’s rule chain never attaches a candidate to an unresolved verdict), so reason is the only evidence a route classified unresolved carries — without it there is no way to tell one unresolved route from another, or to know which follow-up below applies. Every distinct unresolved reason names a different follow-up:

routes[].reason (verbatim, when routes[].classification == unresolved)What a human needs to do
no view template, and controller evidence was unavailable for this runController-shape discovery degraded wholesale for this run (no Ruby, no sidecar, no app/controllers/) — rerun with Ruby/the sidecar available before trusting any verdict near this route, or manually confirm the route’s behavior.
no view template to classifyAn action was recovered but no matching view template exists under app/views/<controller>/<action>.* — confirm by hand whether this action actually renders something (a partial name discovery couldn’t resolve, an unconventional path) or is genuinely backend-only.
unsupported template engine, never convertedThe view uses Haml, Slim, Jbuilder, Builder, or an engine discovery couldn’t identify. Only ERB is proven-safe today; read the template yourself to decide content vs. island vs. something else. The conversion cannot migrate such a route at all — what it can do is let you acknowledge it, through the RAILS_TEMPLATE_ENGINE_UNSUPPORTED finding (§11) that accompanies the blocker.
view reads request-time state / the resolved layout reads request-time state / a rendered partial reads request-time stateThe view, its layout, or a partial it renders references current_user, session, flash, cookies, or a non-route params read. This is still unresolved as a classification, and where the fragment really is request-time state it also surfaces as a RAILS_REQUEST_TIME_STATE finding (§11) with concrete choices — read that finding rather than treating the route as a dead end. The classification does not decide what the conversion does: this rule’s detection is a coarse substring scan, so a route lands here for a csrf_meta_tags call too, and that has a defined conversion. GET / and GET /about in this repository’s fixture classify unresolved for exactly that reason and still report migrated in the handoff (§14).
view looks static but no controller action was recovered to confirm itThe view shows no counter-evidence, but with no recovered action there is nothing to confirm it against. Rerun with controller discovery available, or manually verify the action’s behavior.

A second, disjoint set of unresolved reasons comes from the transitive template SCAN, not from classify.zig’s rule chain above. Verified against this project’s own fixture: GET /posts/featured classifies unresolved with reason template renders a dynamic partial target that cannot be resolved statically — a string that does not appear in the table above at all, because it is produced by rails.zig’s unresolvedRenderReason (transitiveScan downgrading what would otherwise be a content verdict), not by classify.classify. There are four of these, and — unlike every reason in the table above — each one always has a same-route blocker naming the affected file, so a human never has to treat the reason string alone as the only evidence:

routes[].reason (verbatim)Accompanying blockerWhat a human needs to do
template renders a dynamic partial target that cannot be resolved staticallyRAILS_TEMPLATE_RENDER_UNRESOLVEDA render call’s target is a Ruby expression (e.g. render @post), not a literal — confirm by hand which partial it actually resolves to at runtime.
template renders a partial target that does not match any known templateRAILS_TEMPLATE_RENDER_UNRESOLVEDA render call names a literal target that does not match any file this scan found — check for a typo, an unconventional path, or a partial generated some other way.
template's partial nesting exceeds the depth this scan followsRAILS_TEMPLATE_RENDER_DEPTH_EXCEEDEDThe partial chain nests deeper than this scan’s bounded depth — read the blocker’s source.file (where the walk stopped) and the templates below it by hand, or flatten the nesting.
a layout or partial this template renders could not be readRAILS_TEMPLATE_UNREADABLEA file in this route’s template graph could not be read (permissions, a broken symlink) — fix the read failure and rerun; nothing about this route’s true shape is known until then.

A RAILS_TEMPLATE_RENDER_DEPTH_EXCEEDED or RAILS_TEMPLATE_UNREADABLE blocker on a route’s transitive template scan has the same effect one level up: unscanned content is evidence discovery does not have, so a route whose scan hit either condition cannot reach content — it lands on unresolved too, for the identical “no false confidence” reason.

spa is never assigned

classify.Class declares spa because the manifest schema declares it — the design spec reserves the value for a future stage — but no rule chain path ever returns it, and classify.zig pins that with a dedicated test (“spa is never assigned without positive evidence”). Proving that a component root owns routing (rather than just being a mounted island on an otherwise server-rendered page) needs module and import resolution this stage does not perform. A route whose view mounts a component root and would, once that deeper analysis exists, turn out to be an SPA entry point classifies as island today — a true but narrower claim. Do not read the presence of the spa enum value in the schema as a signal that any route will ever actually carry it from this tool.

spa is also the name of a decision choice on RAILS_ROUTE_DYNAMIC_SEGMENT (§14), and the two are unrelated. That one is an instruction you record — “scaffold a .spa.tsx for this declaration” — not a claim discovery made about the Rails app.

3. candidates[] is a separate question from classification

routes[].candidates is not a second opinion on classification — the six-value classification stays the Rails-side fact discovery found. Where present, candidates[] names the still-undecided question of which Zigapagos shape a route might become — e.g. an island route whose only interactivity marker is a Stimulus controller also carries a content candidate, because a Stimulus behavior may be portable to plain static content (a mounted component root never earns that second candidate — it is not portable the same way). Do not conflate “candidate target” with “classification”: a route’s candidates list can be empty, one entry matching its own classification, or (for that one Stimulus case) two.

4. severity vs. integrity: different axes

Every entry in blockers[] (both the manifest’s top-level list and the per-route ones a route_id names) carries two independent fields:

  • integrity (bool) — whether this blocker means the inventory/route data itself cannot be trusted. This is what the exit code is computed from — any blocker with integrity: true makes the run exit non-zero (report and manifest are still written either way; only the exit status changes — see “report, never omit silently” below).
  • severity ("error" | "warn") — descriptive metadata about the finding, for a human or a consumer deciding how loudly to surface it.

These do not derive from each other. A run that simply lacks Ruby on PATH reports RAILS_RUBY_UNAVAILABLE-style blockers at severity: "error" — loud, because route/controller recovery genuinely could not run — but integrity: false, because the inventory scan itself (files, assets, Gemfile) is unaffected and still trustworthy. A consumer that filters severity == "error" will see errors on an otherwise healthy run. Read integrity for “can I trust these counts,” and severity for “how should I present this to a person” — never substitute one for the other.

5. --strict

--strict (Rails only; rejected for every other source) widens the exit-code check: it fails on any blocker at all, severity-blind, not a filtered subset. Concretely (railsExitError in src/cli/migrate.zig):

  • without --strict: exit non-zero iff at least one blocker has integrity: true;
  • with --strict: exit non-zero if either an integrity blocker exists or blockers[] is non-empty at all — including purely descriptive, non-integrity, warn-severity findings like an unsupported template engine or a dynamic route path.

--strict never changes what gets written — MIGRATION.md and the manifest are byte-identical with or without it. It exists for an agent loop or a CI gate that wants “clean discovery, or nothing” as a single exit-code check, without having to parse the manifest’s blockers[] itself.

6. Route identity: routes[].id is not unique

routes[].id is "<VERB> <path>" (e.g. "GET /articles/:id"), formatted by rails.formatRouteId. It is a label for display, not a unique key. Two identical route declarations in config/routes.rb — an unusual but not rejected occurrence — produce the same id. Do not build a map keyed on id and expect one entry per declaration.

The same non-uniqueness applies from the other direction to a blocker’s route_id: RAILS_TEMPLATE_UNREADABLE and similar template-graph blockers are deduplicated per unreadable file, not per affected route. A layout shared by twenty routes that fails to read produces one blocker, and its route_id names whichever route’s scan happened to hit the unreadable file first — not an exhaustive list of the other nineteen. Treat a route_id on a blocker as “at least this one route is affected,” never as “only this one.”

7. What discovery reads, and what it never does

  • Route recovery is a static AST walk (origin: "static_ast" on every route today — "actiondispatch" and "routes_import" are reserved enum values for a future, unimplemented recovery path, not something this version of the tool ever emits) of config/routes.rb, run through a Ruby/Prism sidecar process. The Rails app itself is never booted: no initializer runs, no database connects, no middleware loads.
  • Every route also carries routes[].confidence"certain" or "uncertain" — which is a different claim from classification: confidence is about whether the route itself was recovered reliably, independent of what it was then classified as. "uncertain" means the route was found through a construct the parser can see the shape of but not the activation of (a route declared inside if/unless/case) — treat it as a lead, not a settled fact. MIGRATION.md marks this visibly: an uncertain route’s line carries a — **uncertain** marker between its destination and its classification (e.g. - `GET /x` — **uncertain** — unresolved (...)), and a route can be both uncertain AND classified — those are independent claims, not mutually exclusive ones.
  • templates[].renders == [] ordinarily means exactly what it says: this template renders nothing. That is the common case — 9 of the 12 templates in this project’s own checked-in fixture render with an empty renders, and none of them hit the depth cap. The one other cause collapses to the identical empty value: at RAILS_TEMPLATE_RENDER_DEPTH_ EXCEEDED‘s three-hop partial-nesting cap, the scan stops looking and that node’s renders is empty because its own targets were genuinely never resolved. The two are not distinguishable from renders alone — the disambiguator is blockers[]: only when a RAILS_TEMPLATE_RENDER_DEPTH_ EXCEEDED blocker names this same template’s path was the walk cut short; absent that blocker, an empty renders means what it says. An unreadable template never becomes a templates[] entry at all (its render targets are unknown, not empty), so every entry that DOES appear here was itself successfully read.
  • The layout resolved for a route (routes[].layout) prefers a literal layout declaration on the controller over convention — as of #167 Stage 1; before it, this field was convention-only. See §10 for the exact four-case rule (literal beats convention, a missing declared layout resolves to none, false disables, a dynamic declaration falls back to convention plus a finding). Absent any declaration, the convention is app/views/layouts/<controller>.*, else app/views/layouts/application.*null when neither exists, same as before.
  • Report, never omit silently: even a run that hits a wholesale degradation (no Ruby, no sidecar, an unreadable Gemfile) still writes MIGRATION.md and the manifest — only the exit code (and, honestly, the trustworthiness of the counts) changes. A script should check the exit code and blockers[], not assume a written report means a clean one.

8. --target guards

DIR must not be inside the source tree, and must be missing or empty — the same two guards every other --target use enforces, with one Rails-only exception: a DIR that already exists may contain MIGRATION.decisions.json and nothing else. That exception is what makes the re-run loop (§17) possible: you delete the generated tree and keep your answers. Anything else already in DIR is rejected rather than half-overwritten:

error: migration target '/tmp/site' already exists and is non-empty (only MIGRATION.decisions.json may be kept between runs).

Every file inside the target is written exclusive-create. A pre-existing file that the guard above did not catch is a hard failure, not an overwrite: it means you are writing into a tree you have not wiped, and clobbering it would destroy hand edits.

--runtime-path PATH sets the local @z/runtime package path in the generated package.json, which is written whenever the target has something to build — a .spa.tsx from a spa decision, or an island from a --backend binding or an island answer. Without the flag the path is taken from ZIGAPAGOS_RUNTIME_DIR (the variable build.sh already exports), and only when neither is set does the placeholder file:TODO-SET-RUNTIME-PATH remain. The flag is rejected unless a --target is also given.

What --target then writes is §13.

9. Route names (Stage 1)

Every recovered route’s routes[].name field — always null before #167 Stage 1 — is now filled with the Rails route-helper stem (posts for posts_path/posts_url), derived by the same rules Rails’ own Mapper applies, so a helper in a template can be resolved back to a route without booting the app. name stays null in exactly two cases: the route is uncertain (see §7) — a helper resolving to a route this parser is not vouching for becomes a finding, never a guess — or the route genuinely has no Rails-derivable name (a literal path with a :param/*glob segment and no as:).

The derivation rules (runtime/sidecar/rails/routes.rb’s emit / prefixed_name / derived_name_from_path):

  • resources :posts names its actions off the plural/singular stem: index/createposts; newnew_post; editedit_post; show/update/destroypost. A singular resource :profile (no plural of its own) names create/show/update/destroy profile; new/edit still get the new_<stem>/edit_<stem> prefix regardless of singular/plural, so they are new_profile/edit_profile, not bare profile.
  • member { post :publish }publish_post; collection { get :recent }recent_posts; new { get :preview }preview_new_post — Rails’ <verb-name>_<singular-or-plural> convention for a route declared inside a member/collection/new block.
  • A bare route nested directly in a resources/resource block, with no member/collection/new wrapper (resources :posts do get :stats end) → <parent_singular>_<verb-name> (e.g. post_stats) — name first, segment second, the reverse order from the member/collection case above.
  • A nested resource compounds onto its parent’s singular stem, however many levels deep: resources :posts do resources :comments do resources :replies end end names replies’ own routes post_comment_reply / post_comment_replies, never off the plural.
  • namespace :admin (including its own as: "manage" override) and scope as: "x" push onto an accumulated as_prefix, joined with _, prepended to every name declared inside — independent of the path:/module: prefixes, which have their own overrides.
  • as: — on resources/resource, a verb call, or root — always overrides the derived stem, and every action name re-derives from that override (resources :articles, as: :stories names new_story, not new_article).
  • root → named root (or prefixed, e.g. admin_root).
  • A literal path outside all of the above derives its name from the path itself: segments joined by _, hyphens folded to underscores (get "/about-us"about_us).
  • Any segment that is a :param or a *glob makes the route nameless — Rails would not generate a helper for it either.
  • A non-literal as: (a method call, an interpolated string) is RAILS_ROUTE_DYNAMIC_PATH, the same unresolved code every other unresolvable literal argument in this parser already reports — the route it would have named is not emitted with a guessed name.

A singular resource :x routes to the PLURAL controller — Rails’ SingletonResource#controller is options[:controller] || name.to_s.pluralize — so resource :profile is ProfilesController and resource :person is PeopleController, while the path and every helper name stay singular (/profile, profile_path, new_profile_path). The parser pluralises with an order-preserving port of ActiveSupport::Inflector’s own plural rules (runtime/sidecar/rails/inflect.rb), uncountables included: resource :series is SeriesController, not SeriesesController. An explicit controller: still wins.

This closes issue #176, and the earlier advice to write controller: on every singular resource is withdrawn — that workaround is no longer needed and real Rails apps do not carry it. The bug was not a cosmetic label: a route whose controller does not resolve reaches no template, so it raised no finding from that template and produced no page, which made an app’s entire sign-in flow invisible to the migration and stopped the auth-journey detection of §18 — which keys on the sessions/registrations controller names — from ever firing on an idiomatic app.

10. Layouts (Stage 1)

routes[].layout now takes a controller’s own declared layout into account, not the convention-only scan the field used to run (see §7’s layout bullet for the before/after). The four cases, in priority order:

  • A literal layout "x" beats convention. If a matching app/views/ layouts/x.* exists on disk, that is the resolved layout — even when a per-controller or app-wide default layout also exists.
  • A declared layout absent on disk resolves to no layout at all — not the convention fallback. Rails raises at render time for a missing named layout; silently substituting application here would report a template graph the app never actually renders.
  • layout false disables the layout entirely. routes[].layout is null, the same shape as “no layout found.”
  • A symbol, a proc, or a literal carrying only:/except: is dynamic. Any of these is decided per request, which this static walk cannot resolve — the layout falls back to convention (so the route still gets a plausible layout to classify against) and a RAILS_LAYOUT_DYNAMIC finding is emitted, naming the controller file and the declaration’s line, so the approximation is visible rather than silent.
  • self.layout "x" is the identical class-level call as the bare layout "x" form and is read the same way; a layout call on any other receiver (Foo.layout) is not this controller’s own declaration and is ignored.

11. Findings

findings[] is the manifest’s new top-level array (the last key, after blockers[] — see §1): a per-fragment or per-declaration question for the operator, not a fact discovery already settled. A blocker states what discovery could or could not establish; a finding states a decision a converter cannot make on the operator’s behalf, with a fixed list of choices. A finding is never a blocker in disguise: it never makes MIGRATION.md or the manifest any less trustworthy, and it does not enter the exit-code check blockers[] drives, with or without --strict. What it can do is leave a route unanswered on a --target run, which is exit 3 — a separate outcome from a blocker’s exit 1, and distinguishable precisely so a loop can tell “decide more” from “this run is broken” (§16).

A real entry, from this repository’s own tests/migrate/rails-presentation fixture:

{
  "id": "RAILS_HELPER_UNKNOWN.app/views/pages/help%2Ehtml%2Eerb.L1C18",
  "code": "RAILS_HELPER_UNKNOWN",
  "severity": "warn",
  "source": { "file": "app/views/pages/help.html.erb", "line": 1 },
  "route_id": null,
  "message": "unknown helper `number_to_currency`",
  "choices": ["island", "retain", "blocked"],
  "requires_artifact": false
}
  • id is <code>.<path>.<loc>, with % escaped to %25 and . to %2E (in that order, so the mapping is reversible) in each of code and path; loc is L<line>C<col> for a template-node finding, or L<line> for a parse-error or layout finding, or the word unscanned for a view the fragment analysis never read. id is stable across a reworded message or a template edit elsewhere in the file — a future stage’s decision file references a finding by id, never by array position or message text. col is the 1-based source column of the fragment, not of the <% %> tag around it: one tag can hold several fragments (<% number_to_currency(1); pluralize(2) %>), and each gets its own column so each gets its own id.
  • code is the stable, machine-greppable identifier (RAILS_HELPER_UNKNOWN, RAILS_LAYOUT_DYNAMIC, …).
  • severity"warn" or "error" — the same two-value type blockers[] uses, but per the note above it never touches the exit code the way a blocker’s integrity does.
  • source{file, line}.
  • route_id is null for every template- or controller-scoped finding, which is most of them. The route-scoped codes — RAILS_ROUTE_DYNAMIC_SEGMENT, RAILS_REDIRECT_HOST_CONFIG, RAILS_NO_TEMPLATE, RAILS_CONTENT_PATH_COLLISION, RAILS_ROUTE_PATH_UNSUPPORTED, RAILS_ROUTE_AUTH_GUARD, RAILS_AUTH_JOURNEY and the route-level shape of RAILS_BACKEND_ENDPOINT — set it, and their source is config/routes.rb at the line the route was declared on. Even there it names one affected route, not all of them: a single resources :posts line yields several dynamic routes that share one finding id, and message lists them all (see the table below). RAILS_BACKEND_ENDPOINT is the one code that does not fold a whole line into one question — §18 explains why, and what its longer id looks like.
  • message is human prose, explicitly not part of id; reword it freely without invalidating a previously recorded decision.
  • choices is the fixed set of answers you may record against this finding. It is a property of the finding, not of its code: two findings with the same code can offer different lists, and RAILS_REQUEST_TIME_STATE actually does (below). Read the choices array on the finding you are answering — never a remembered list per code.
  • requires_artifact is true for exactly one code, RAILS_AUTH_JOURNEY, and false for every other finding this version emits. When it is true an answer whose choice produces something (island) is rejected without an artifact; retain and blocked never need one, because they produce nothing for an artifact to name. For the auth journey the artifact is the ZigBase auth collection name, and it is checked against the --backend document — see §18.

findings[] is sorted by (code, path, line, id). MIGRATION.md renders a separate ## Findings section, after ## Blockers on purpose — the two must never be mixed into one list a reader could mistake for a single kind of thing. It says None. when the array is empty rather than omitting the section; otherwise it prints a count line per distinct code, then one line per finding — both also real output from the same fixture run:

## Findings

- RAILS_AUTH_JOURNEY: 1
- RAILS_BACKEND_ENDPOINT: 2
- RAILS_HELPER_UNKNOWN: 1
- RAILS_I18N_UNRESOLVED: 1
...

- `RAILS_AUTH_JOURNEY` `config/routes.rb:38` — auth journey: DELETE /session, GET /session/new, POST /session, GET /registration/new, POST /registration; island needs artifact = the ZigBase auth collection name (in --backend: users) (choices: island, retain, blocked)
- `RAILS_BACKEND_ENDPOINT` `app/views/shared/_nav.html.erb:5` — link performs a mutation: button_to `Sign out` method=delete (choices: deletePosts, deleteUsers, retain, blocked)
- `RAILS_BACKEND_ENDPOINT` `config/routes.rb:20` — route is API traffic and needs a backend operation: GET /feed (choices: listPosts, viewPosts, listUsers, viewUsers, retain, blocked)
- `RAILS_HELPER_UNKNOWN` `app/views/pages/help.html.erb:1` — unknown helper `number_to_currency` (choices: island, retain, blocked)
...

(Those operation ids are the ones this fixture’s own backend/openapi.json declares, offered because the run passed --backend. Without the flag the same two findings read (choices: retain, blocked)§18.)

These are the codes — src/cli/rails/findings.zig‘s derivation table is the single source of truth. The Choices column below is what that table attaches today; it is documentation of the code path, not a substitute for reading the individual finding’s own choices array.

CodeTriggerChoices
RAILS_HELPER_UNKNOWNa fragment classifies unknown — outside the closed vocabulary, §12island, retain, blocked
RAILS_REQUEST_TIME_STATEcurrent_user/session/flash/cookies/non-route params/request./policy(/… or a bare @ivarisland, spa, backend, retain, blocked
RAILS_REQUEST_TIME_STATE (again)an errors fragment — @x.errors.full_messages, errors.any?, f.object.errors[:y]. Same code, different question: not “where does this state come from” but “how is the validation state that comes back presented”island, retain, blocked
RAILS_I18N_UNRESOLVEDt("key") has no entry under the default localeretain, blocked
RAILS_CONTENT_FOR_DYNAMICa view’s top-level content_for :title block whose body is not one literal value; the block cannot become static .title frontmatter and rendering it inline would put title markup in the page bodyretain, blocked
RAILS_LOCAL_UNBOUNDa template local has no literal binding from its render site and is not owned by an enclosing answerable regionretain, blocked
RAILS_RAW_OUTPUT<%== %>, raw(...), .html_safeisland, retain, blocked
RAILS_PARTIAL_DYNAMICrender @x, collection:, or non-literal locals:island, spa, retain, blocked
RAILS_PARTIAL_UNRESOLVEDa literal partial target cannot be located or converted, or the render edge closes a cycleretain, blocked
RAILS_ROUTE_HELPER_DYNAMICa *_path/*_url helper (or link_to’s route target) has non-literal argumentsisland, spa, retain, blocked
RAILS_ROUTE_HELPER_UNKNOWNa route helper’s name matches no certain named route (an unnamed or uncertain route)retain, blocked
RAILS_ROUTE_HELPER_UNRESOLVEDa known route helper cannot build a URL from the literal arguments supplied, including wrong arityretain, blocked
RAILS_TEMPLATE_CONTROL_FLOWif/unless/case/while/until (also a bare loop over a plain local through one of templates.rb‘s five CONTROL_CALLS: each, each_with_index, map, times, each_slice) whose branch predicate classifies as literal, local, or unknown — i.e. nothing more specific applies; a request-state/ivar/errors predicate takes that kind instead (and that kind’s own finding, or none for errors)island, spa, retain, blocked
RAILS_TEMPLATE_PARSE_ERRORa template’s Ruby fragments do not assemble into valid Rubyretain, blocked
RAILS_TEMPLATE_ENGINE_UNSUPPORTEDa route-reachable template in an engine no converter reads — Haml, Slim, Jbuilder, Builder, or one discovery could not identify. Also a blocker, and the only code that is both: the blocker explains the engine in the report and counts under --strict, the finding is what an operator can answer. Its line is null and its loc is the word engine (nothing ever parsed the file, the same reason RAILS_TEMPLATE_UNSCANNED uses unscanned), so its id is e.g. RAILS_TEMPLATE_ENGINE_UNSUPPORTED.app/views/posts/legacy%2Ehtml%2Ehaml.engineretain, blocked
RAILS_TEMPLATE_UNSCANNEDthe fragment analysis refused the view outright — it resolved outside the app root, or could not be read at the moment the analysis ran (a file replaced or removed mid-run). Distinct from the RAILS_TEMPLATE_UNREADABLE blocker, which is the earlier template-graph scan failing to read a file: here that scan succeeded, so nothing else in the manifest mentions the view at all. Its loc is the word unscanned, not an L<line> — the file was never parsed, so there is no line to point atretain, blocked
RAILS_LAYOUT_DYNAMICa controller’s layout declaration is a symbol, a proc, or carries only:/except:retain, blocked
RAILS_ASSET_TRANSFORMan asset helper (the ten of templates.rb’s ASSET_HELPERS, minus the two below) whose literal names no file under app/assets/ or public/, or names one whose public URL could not be derived without guessing (assets[].deterministic == false). javascript_include_tag and favicon_link_tag never raise it: both are dropped outright with the JS-entry family, so there would be no placeholder for an answer to fillretain, blocked
RAILS_ROUTE_DYNAMIC_SEGMENTa GET/HEAD route whose path has a :param or *glob segment, and which is neither backend nor redirect. Route-scoped, one per routes.rb declaration rather than per route — a resources :posts line is one question, and message names every route it coversspa, retain, blocked
RAILS_REDIRECT_HOST_CONFIGa route classified redirect. Route-scoped, same one-per-declaration folding. The static tree cannot express a redirect; the host-config emitters own itretain, blocked
RAILS_NO_TEMPLATEa GET/HEAD route, neither backend nor redirect nor dynamic, whose <controller>/<action> matches none of the templates discovery resolved for it — an action that renders another template (def other; render :about; end), or one whose view was deleted. Route-scoped, same one-per-declaration folding, so its id is e.g. RAILS_NO_TEMPLATE.config/routes%2Erb.L56. There is no template, so no answer produces a page: the choice is whether the URL stays on Rails or does not shipretain, blocked
RAILS_BACKEND_ENDPOINTthree shapes, all one code (§18): a form/form_field fragment (only the outermost form asks — one form with twelve fields is one decision, not thirteen; a stray form_field outside any form still raises its own); a link_to/button_to that performs a mutation (method: other than get, or data-turbo-method/data-method); and a non-GET or JSON-rendering route, keyed on (routes.rb line, verb, resource) rather than on the line alonethe --backend document’s own operation ids for that verb, resource-first; then retain, blocked. custom:/<path> is additionally accepted. Without --backend: retain, blocked
RAILS_AUTH_JOURNEYthe app’s whole sign-in/sign-up flow, folded into one question keyed on the smallest routes.rb line any journey route was declared on. requires_artifact: true — the artifact is the ZigBase auth collection (§18)island, retain, blocked
RAILS_ROUTE_AUTH_GUARDa page route whose controller (or a class it inherits from) runs a before_action whose symbol name contains login, auth, sign or user, and does not skip_before_action it. A static page cannot enforce the guard, and shipping it silently public is the thing this migration refuses to dopublic, retain, blocked
RAILS_CONTENT_PATH_COLLISIONa route whose content path another route already claimed (/about and /about/ normalise to one path). Route-scoped. Closes the id-less half of issue #182retain, blocked
RAILS_ROUTE_PATH_UNSUPPORTEDa route whose path this stage cannot reduce to a content path at all (GET /posts(.:format)). Route-scoped, the other half of #182retain, blocked
RAILS_STIMULUS_CONTROLLERan element carrying data-controller; the element extent, named controller source and every action descriptor must be structurally followable before island is offeredisland, drop, retain, blocked when portable; otherwise drop, retain, blocked
RAILS_TURBO_FRAMEa turbo_frame_tag fragment or <turbo-frame> elementisland, retain, blocked for a resolvable non-API src; inline, retain, blocked for a closed source-less frame; otherwise retain, blocked
RAILS_TURBO_STREAMa turbo_stream_from/turbo_stream.* fragmentisland-realtime, retain, blocked when the stream/action target is literal and the action is supported; otherwise retain, blocked
RAILS_COMPONENT_ROOTa react_component("Name", {…}) mount point with literal props and a bundleable, version-pinned import closureisland, retain, blocked when portable; otherwise retain, blocked
RAILS_COMPONENT_PROPS_DYNAMICa React component root whose props contain request-time Rubyretain, blocked
RAILS_COMPONENT_VUE_UNSUPPORTEDan element carrying data-vue-component; also a non-integrity warning blockerretain, blocked
RAILS_JS_ENTRYthe one recovered Rails JavaScript entry; its imports are listed but the file is not executed or copieddrop, blocked

These rows ensure every unfinished interactive region is at least acknowledgeable. Stage 4 also gives the portable Stimulus, frame and React shapes real answers that emit files; the exact gates and output are in §19. Portable Turbo streams gain an explicit realtime answer; dynamic stream shapes and Vue stay acknowledgeable rather than being approximated.

RAILS_TEMPLATE_ENGINE_UNSUPPORTED is the one code that is a blocker and a finding, and the pairing is deliberate. Without the finding a Haml view had no id any decision could name, so a route reaching one could never be answered and complete was unreachable for any app with a single Haml template. The finding does not make such a route convertible — it can never be migrated — it makes it acknowledgeable, which is what “never silently complete” requires.

One more code appears in blockers[] only, never findings[]: RAILS_DECISION_STALE — see §15.

Only the default locale’s t() keys are resolved (config.i18n. default_locale, else en); a key that only exists in a non-default locale still reports RAILS_I18N_UNRESOLVED — non-default locales are out of scope for this stage entirely, not a partial best-effort.

A config/locales/** file that fails to load (a YAML syntax error, a read failure, or a construct the loader deliberately refuses) is skipped, not fatal, which leaves the translation table empty or partial — and then every t() key in the app looks missing. That would be N RAILS_I18N_UNRESOLVED findings blaming the templates for a broken YAML file, so discovery says so twice: one RAILS_I18N_LOCALE_UNREADABLE blocker (warn, non-integrity — the inventory and the route graph are unaffected) per file that failed, naming the file and the Ruby error; and a caveat appended to every RAILS_I18N_UNRESOLVED message for that run — ”— a locale file failed to load: config/locales/en.yml. The findings keep their code, their choices and their id: the key really does not resolve and the decision is unchanged; what the caveat fixes is the reason the operator would otherwise infer.

The sidecar parses locale files from untrusted third-party apps, so what counts as “a construct the loader deliberately refuses” above is itself deliberate: YAML aliases (*ref) are not accepted in locale files — inline the referenced block — because expanding one lets a small file balloon into a huge one (“billion laughs”); an anchor definition (&ref) alone is not itself the hole and loads fine, since nothing expands until something references it. permitted_classes: [Symbol, Date, Time] accepts three plain-scalar tags without reopening that hole: Symbol, because Rails’ own shipped locale files use one (date.order: [:year, :month, :day]) and apps copy that idiom; Date and Time, because Psych’s core schema resolves an untagged scalar like since: 2024-01-01 to one of them with no explicit tag at all, and Rails itself loads such a file without complaint. None of the three is an instantiation or size-expansion vector under safe_load; a non-String leaf (Symbol, Date, Time, or otherwise) is simply ignored by lookup rather than resolved.

12. The fragment vocabulary

erb.rb scans a template with Erubi’s grammar; templates.rb compiles its Ruby fragments into one program, parses it once with Prism, and classifies each fragment into one of the kinds below — block structure (do…end, if…else…end) becomes real, explicit block_else/block_end nodes in the stream rather than being inferred from indentation. A template’s Haml/Slim sibling is never sent through this op at all — it already carries RAILS_TEMPLATE_ENGINE_UNSUPPORTED and asking an ERB parser to scan non-ERB source would only manufacture a parse error on top of a finding that already exists. This table is the design spec’s own “Fragment vocabulary” table with a fourth column added: the bytes the converter actually emits for that kind, verified against this repository’s own fixture. Every kind is now either converted outright or turned into an answerable region — see §14 for the three markers.

kindRuby shapeconversionwhat --target emits
yieldyield<super> inside the block element id="main"<div id="main"><super></div>
yield_namedyield :head, content_for?(:x)named <super> block id="<name>"a <super> block under that id. :title is the exception — see §14
content_forcontent_for :x do … end, provide(:x, "literal")child block <… id="<name>"><div id="<name>">…</div>; dropped with an OPEN note when the layout declares no block x; a view’s top-level content_for :title whose BODY is not a single literal raises RAILS_CONTENT_FOR_DYNAMIC, because request-time output cannot become static .title frontmatter. A computed name never reaches this kind at all: templates.rb’s classifier requires a literal first argument (return {kind: "content_for", …} if literal(args.first)), so <% content_for(name) do %> falls through to unknown and raises RAILS_HELPER_UNKNOWN
render_partialrender "x", render partial: "x" with no locals:/collection:inline expansion of the converted partialthe partial’s converted bytes, inline at the render site; RAILS_PARTIAL_UNRESOLVED when the target resolves nowhere, is cyclic, or names a template that failed to parse or could not be read
render_partial_localssame with locals: of literals onlyinline expansion with literal substitutionsame, with each literal local substituted — and the same answerable refusals
render_dynamicrender @x, collection:, non-literal localsfinding RAILS_PARTIAL_DYNAMICrails:finding region, RAILS_PARTIAL_DYNAMIC
route_helper<name>_path, <name>_url, no args or literal argsthe route’s path, literals substitutedthe route’s URL, each literal argument percent-encoded per RFC 3986; RAILS_ROUTE_HELPER_UNKNOWN when the name matches no certain route, or RAILS_ROUTE_HELPER_UNRESOLVED when the literal arguments cannot build it
route_helper_dynamicargs are not literalsfinding RAILS_ROUTE_HELPER_DYNAMICrails:finding region, RAILS_ROUTE_HELPER_DYNAMIC
link_tolink_to "text", <route_helper> [, html_opts literals]<a href="…">text</a>, unless it mutates<a href="/about">About</a> for a navigation link (also covers button_to); a rails:finding region when the route name is unknown. A link that performs a mutationmethod: other than get, data-turbo-method, or the rails-ujs data-method, and every button_to without an explicit get — is a RAILS_BACKEND_ENDPOINT question instead: unanswered it is an empty rails:finding region (never an <a> that GETs a DELETE route), answered it is a click island (§18)
assetthe ten helpers in templates.rb’s ASSET_HELPERS: image_tag, image_path, asset_path, asset_url, stylesheet_link_tag, javascript_include_tag, favicon_link_tag, audio_tag, video_tag, font_path$site.asset('…').link() when assets[] has it deterministic; else RAILS_ASSET_TRANSFORM<img src="$site.asset('images/logo.png').link()"> / <link rel="stylesheet" href="$site.asset('…').link()"> / a bare $site.asset('…').link() for image_path/asset_path/asset_url/font_path/the media helpers; a rails:finding region (RAILS_ASSET_TRANSFORM) when any argument does not resolve deterministically. An absolute URL argument (http://…, https://…, or the protocol-relative //host/…) is emitted verbatim and raises nothing — <%= image_tag "https://cdn.example.com/x.png" %> becomes <img src="https://cdn.example.com/x.png">; the resource is on another host, so there is no local file to match and nothing to copy. stylesheet_link_tag "a", "b" emits one <link> per argument, and one unresolvable argument makes the whole node a region even if another argument was absolute. javascript_include_tag and favicon_link_tag are the two exceptions — they take the drop path below instead
importmapjavascript_importmap_tags, turbo_include_tagsdropped<!-- rails: javascript_importmap_tags dropped; @z/runtime replaces the Rails JS entry --> and an informational note. No finding: @z/runtime and the island bundle replace the Rails JS entry wholesale, so there is nothing to decide. javascript_include_tag/favicon_link_tag take this path too
csrfall three of templates.rb’s CSRF_HELPERS: csrf_meta_tags, csrf_meta_tag, csp_meta_tagdropped; noted in MIGRATION.md (ZigBase cookie/CSRF boundary owns this)<!-- rails: csrf_meta_tags dropped; the ZigBase cookie/CSRF boundary owns this --> and an informational note
i18nt("key"), t(".key"), I18n.tresolved literal; RAILS_I18N_UNRESOLVED if missingthe resolved string, HTML-escaped; a rails:finding region when the key is missing from the default locale
literalstring/number/nil/true/falseHTML-escaped textthe value, HTML-escaped
formform_with/form_for/form_tag block and its f.* builder callsa form bound to a backend operationrails:finding region, RAILS_BACKEND_ENDPOINT; answered against a --backend document it becomes <island src="components/forms/….island.tsx" client:load> and the region’s ERB is gone (§18). A form in a sign-in/sign-up view asks no question of its own — the one RAILS_AUTH_JOURNEY finding is its question
form_fieldf.text_field :title, f.label, f.submit, f.check_box, f.select with literal options, f.text_area, f.email_field, f.password_field, f.hidden_fieldfield descriptor inside the enclosing formfolded into the enclosing form’s one region; only a field with no enclosing form raises its own
errors@x.errors.full_messages, errors.any?, f.object.errors[:y]the form’s validation-presentation regionrails:finding region, RAILS_REQUEST_TIME_STATE with island/retain/blocked. A summary naming the bound form’s own model (@post.errors beside form_with model: @post), or any summary in a template whose bound form declares no model, is absorbed into that form’s island and emits no region at all — the island renders the backend’s field errors where the ERB rendered full_messages
request_statea receiverless call to one of templates.rb‘s fourteen REQUEST_STATE names — current_user, session, flash, cookies, params, request, signed_in?, logged_in?, user_signed_in?, current_account, current_organization, policy, can?, authorizeor any name starting with current_, which is what generalises an app’s own current_tenant-style helpers, or a read of the Current constant (Current.user, Current.account: Rails’ ActiveSupport::CurrentAttributes singleton, which is per-request state under a constant rather than a method — templates.rb‘s state_or matches it as a Prism::ConstantReadNode named Current, and the finding’s message names it as Current)finding RAILS_REQUEST_TIME_STATErails:finding region, RAILS_REQUEST_TIME_STATE with all five choices
ivar@anything outside the shapes abovefinding RAILS_REQUEST_TIME_STATEsame as request_state
controlif/unless/case/each whose condition classifies as literal, local or unknown (a request-state/ivar/errors condition takes that kind instead)finding RAILS_TEMPLATE_CONTROL_FLOWrails:finding region around the whole block, with <!-- rails:else --> separating its branches
turbo_frameturbo_frame_tag or <turbo-frame>island fetches a resolvable src; inline preserves a closed source-less frameshared components/TurboFrame.island.tsx with client:load (client:visible for loading="lazy"), or the inline frame; otherwise a RAILS_TURBO_FRAME region
turbo_streamturbo_stream_from, turbo_stream.*portable literal shapes offer island-realtimeshared components/TurboStream.island.tsx; dynamic targets and unsupported actions remain a rails:finding region
component_rootreact_component("Name", {…})literal, bundleable React roots become compatibility islandscomponents/<Name>.island.tsx, unchanged sources under components/react/, and literal props; dynamic props become RAILS_COMPONENT_PROPS_DYNAMIC
stimulusan element carrying data-controllerisland structurally wires targets, values, classes and actions; drop removes its Stimulus data attributeswrapping components/stimulus/<controller>.island.tsx; inner findings remain open
vue_rootan element carrying data-vue-componentno runtime bridgerails:finding region, RAILS_COMPONENT_VUE_UNSUPPORTED, plus a warning blocker
raw<%== %>, raw(...), .html_safefinding RAILS_RAW_OUTPUT — unescaped output is never passed throughrails:finding region, RAILS_RAW_OUTPUT. The unescaped bytes are never emitted
comment<%# %>droppednothing — erb.rb’s tokenizer consumes a comment tag and emits no token for it at all, so no comment node ever reaches templates.rb
locala bare template-local (a block param, an each variable)the value the render site bound to itthe literal from the render site’s locals:; an unbound block local inside a finding belongs to that answerable region; otherwise RAILS_LOCAL_UNBOUND
unknowneverything elsefinding RAILS_HELPER_UNKNOWNrails:finding region, RAILS_HELPER_UNKNOWN

erb.rb/templates.rb also produce two purely structural node kinds this table’s spec original does not list because they carry no finding of their own and never will: block_else/block_end (the else/elsif/when/in/end fragment closing an emitted block). They are what makes block structure explicit on the wire rather than inferred from indentation, and the converter reads them to place <!-- rails:else --> and close each region. local is listed above but is worth distinguishing here: it is a template-local variable (a block param, an each loop variable), never request-time state — that is ivar. A plain text run between tags never produces a finding either, regardless of its content — only a classified Ruby fragment can.

The templates op refuses to read outside the Rails app root: a request naming an absolute path, a path containing .., or a path that resolves (after following symlinks) outside the app root’s own resolved path comes back unreadable: "outside root" rather than being read — the check is applied twice, once cheaply on the unresolved path and once again with File.realpath on the resolved one, so a symlink that itself points outside the root cannot be used to read arbitrary files from the machine running discovery.

Inventory includes file symlinks under their authored app-relative paths. View symlinks are then accepted only when File.realpath remains inside the app root; controller symlinks use the same rule inside app/controllers. This supports shared in-repository views/controllers without allowing either sidecar operation to read through an app symlink into unrelated host files.

Self-contained Ruby blocks written wholly inside one ERB tag contribute only their enclosing classified node: their Ruby-local body and end are not separate rendered fragments. fields_for introduces its nested form builder, block-form link_to retains the route helper it targets, and non-literal asset helpers remain explicit dynamic asset nodes. A content_for?(:x) ? yield(:x) : default shape is reported as RAILS_NAMED_YIELD_DEFAULT with retain/blocked, because SuperHTML’s void <super> cannot reproduce Rails’ conditional fallback without runtime logic.

13. --target writes a project

This is the exact tree zigapagos migrate tests/migrate/rails-presentation --from rails --target DIR --backend tests/migrate/rails-presentation/backend/openapi.json writes for this repository’s own fixture — an eighteen-route Rails app — on a first run, with no decisions file. Run 1 binds nothing: no lib/, no components/, no package.json, because an unanswered question produces no island.

DIR/.gitignore
DIR/AGENTS.md
DIR/CLAUDE.md
DIR/MIGRATION.handoff.json
DIR/MIGRATION.manifest.json
DIR/MIGRATION.md
DIR/assets/images/logo.png
DIR/assets/robots.txt
DIR/assets/stylesheets/application.css
DIR/build.sh
DIR/content/about/index.smd
DIR/content/help/index.smd
DIR/content/index.smd
DIR/content/linked/index.smd
DIR/content/links/index.smd
DIR/content/live/index.smd
DIR/content/posts/index.smd
DIR/content/registration/new/index.smd
DIR/content/session/new/index.smd
DIR/content/widgets/index.smd
DIR/layouts/pages/about.shtml
DIR/layouts/pages/help.shtml
DIR/layouts/pages/linked.shtml
DIR/layouts/pages/links.shtml
DIR/layouts/pages/live.shtml
DIR/layouts/pages/widgets.shtml
DIR/layouts/posts/index.shtml
DIR/layouts/registrations/new.shtml
DIR/layouts/sessions/new.shtml
DIR/layouts/templates/application.shtml
DIR/layouts/templates/marketing.shtml
DIR/zigapagos.ziggy

and it says so on stderr, then exits 3:

Assembled DIR: 10 page(s), 3 asset(s), 13 route(s) open.
Wrote DIR/MIGRATION.md: Rails, inventory plus 18 recovered route(s).
Next: follow MIGRATION.md.
13 route(s) open -- answer the findings in MIGRATION.handoff.json via MIGRATION.decisions.json and re-run.

The last line names the file you have to write. On a first run that is the default basename, as above; when you passed --decisions FILE it is that path verbatim, so the instruction is always one you can act on without guessing where your answers live.

PathWhat it is
content/<url>/index.smdOne per migrated route. Frontmatter only — the page body lives in the .shtml, because SuperMD forbids raw HTML.
layouts/<view stem>.shtmlOne per converted view, named from the view’s path under app/views/ with every extension dropped (app/views/pages/about.html.erblayouts/pages/about.shtml). The controller directory is part of the stem on purpose: two controllers routinely have an index view.
layouts/templates/<layout stem>.shtmlOne per converted Rails layout (app/views/layouts/marketing.html.erblayouts/templates/marketing.shtml), written once however many routes reach it.
assets/…Copied sources, app/assets/ and public/ prefixes stripped: app/assets/images/logo.pngassets/images/logo.png, public/robots.txtassets/robots.txt. public/assets/** is excluded: that is the asset pipeline’s compiled output, and every file in it is a digested copy of an app/assets/ source the conversion already copies from the source side. See §14.
zigapagos.ziggycontent_dir_path/layouts_dir_path/assets_dir_path wired to the three directories above, .title from the Rails app’s own directory basename (falling back to the target’s, then to migrated_site), and .static_assets = ["**"] only when something was actually copied. host_url is "https://example.com" — a placeholder you edit, not a value read from Rails config.
build.shexec "${ZIGAPAGOS_BIN:-zigapagos}" release --force --output=zig-out/site "$@", plus one quoted --spa='spa/<seg>.spa.tsx|/<seg>' per scaffolded SPA and a bun install line when there is a package.json.
.gitignore, AGENTS.md, CLAUDE.mdThe same three files every other source’s --target writes.
spa/<segment>.spa.tsxOnly when a spa decision was recorded — §14.
components/…island.tsx, components/react/…, lib/stimulus.ts, lib/zb.tsWritten only for answers that produce their corresponding backend or interactivity artifact — §18, §19. Shared libraries are keyed on an island actually being written.
package.json, tsconfig.jsonOnly alongside a .spa.tsx or an island: a pure content target builds with the binary alone, and a package.json nothing installs would invite bun install into a project with no JS. Its @z/runtime dependency is a placeholder unless --runtime-path or ZIGAPAGOS_RUNTIME_DIR gave it one — see The generated project builds below, because bun install fails on the placeholder. An island also adds "@zigbase/client": "0.3.0".

Note what is not here: the fixture has eighteen routes and ten content/ pages. A route that is backend, redirect, dynamic-and-undecided, answered retain or blocked, or whose view would not convert writes no page — and an open route may still have written one (its page exists; it is the decision that is missing). Read the handoff, not the tree, to find out which is which.

A route you answered retain or blocked writes no page either, and no layouts/<view stem>.shtml of its own: retained means the page stays on Rails, so this target must not answer that URL, and blocked means it does not ship. Re-running the fixture with its checked-in MIGRATION.decisions.json therefore drops content/help, content/links and content/live and their view files — and gains the files the answers produced. The whole answered tree, verbatim:

DIR/.gitignore
DIR/AGENTS.md
DIR/CLAUDE.md
DIR/MIGRATION.decisions.json
DIR/MIGRATION.handoff.json
DIR/MIGRATION.manifest.json
DIR/MIGRATION.md
DIR/assets/images/logo.png
DIR/assets/robots.txt
DIR/assets/stylesheets/application.css
DIR/build.sh
DIR/components/AuthForm.island.tsx
DIR/components/AuthStatus.island.tsx
DIR/components/Chart.island.tsx
DIR/components/TurboFrame.island.tsx
DIR/components/data/posts_index.island.tsx
DIR/components/react/Chart.jsx
DIR/components/stimulus/reveal.island.tsx
DIR/content/about/index.smd
DIR/content/index.smd
DIR/content/linked/index.smd
DIR/content/posts/index.smd
DIR/content/registration/new/index.smd
DIR/content/session/new/index.smd
DIR/content/widgets/index.smd
DIR/layouts/pages/about.shtml
DIR/layouts/pages/linked.shtml
DIR/layouts/pages/widgets.shtml
DIR/layouts/posts/index.shtml
DIR/layouts/registrations/new.shtml
DIR/layouts/sessions/new.shtml
DIR/layouts/templates/application.shtml
DIR/layouts/templates/marketing.shtml
DIR/lib/zb.ts
DIR/lib/stimulus.ts
DIR/package.json
DIR/spa/posts.spa.tsx
DIR/tsconfig.json
DIR/z-runtime.config.json
DIR/zigapagos.ziggy

The handoff row is the record that the route was considered; the tree holds only what the site serves. (layouts/templates/<layout stem>.shtml is unaffected — a layout is shared chrome, written once per layout rather than per route, so one can outlive every page that extended it.)

Everything is a pure function of the discovery result and your decisions file: no timestamps, no absolute paths, no ambient state. Two runs over the same app produce byte-identical trees.

The generated project builds

ZIGAPAGOS_BIN=/path/to/zigapagos bash DIR/build.sh

That is zigapagos release --force --output=zig-out/site, and it produces a real static site — content/about/index.smd becomes zig-out/site/about/index.html with the layout’s chrome around it, and the copied assets land at the URLs the $site.asset(...).link() expressions resolve to. A route left open still builds: its unfinished regions are HTML comments, so the page renders and the rails:finding markers are visible in the output for whoever finishes it by hand.

A target with JS needs @z/runtime pointed somewhere real first — a SPA, an island, or both. Where the path comes from, in order:

  1. --runtime-path <path>/runtime on the migrate call that scaffolds it. Always wins.
  2. ZIGAPAGOS_RUNTIME_DIR, which the run reads from its own environment (issue #179). It is the same variable the Rails sidecar and the bundlers already need when you drive zigapagos out of a checkout rather than an installed release, so in that setup the dependency is written resolved with nothing extra to remember.
  3. Neither: a deliberate placeholder.
"dependencies": { "@z/runtime": "file:TODO-SET-RUNTIME-PATH" }

build.sh starts with bun install, so building the placeholder as-is fails before release is ever reached:

error: Could not find package.json for "file:TODO-SET-RUNTIME-PATH" dependency "@z/runtime"
error: @z/runtime@file:TODO-SET-RUNTIME-PATH failed to resolve

The handoff says so too — the spa-decided route’s note reads set dependencies.@z/runtime in package.json. Editing that one line in package.json afterwards works as well as either of the two inputs above. Any of the three, and bash build.sh exits 0. A target with no SPA and no island has no package.json at all and needs none of them.

A scaffolded SPA carries the deterministic stylesheet links recovered from its Rails layout in spa.head. A SPA shell has a fixed <head> and does not inherit the site’s links, so this explicit list is what keeps the generated record routes styled. Review it when the old layout computed assets at request time.

zigapagos doctor on the built site reports 0 errors, and a warning per link into a route you did not migrate. On the fixture’s answered run that is now none:

doctor: 0 errors, 0 warnings across 8 files

It used to be three, all the same dangling-internal-link on href '/session/new': the shared _nav partial linked to the sign-in page, and /session/new was answered retain, so Rails served it and this tree did not. Answering the auth journey island instead migrates that route — the target now serves /session/new/ — and the nav’s own link is gone anyway, because the AuthStatus island replaces the whole current_user region and renders its own <a href="/session/new"> at runtime (§18).

A warning of that shape is still what you should expect from a partial migration, and it is not a defect in the conversion: a link into a route you answered retain or blocked genuinely resolves to nothing in this tree while Rails still answers it. Fix it by migrating the route, by pointing the link at the Rails origin, or by accepting it until you do.

14. The conversion rules

What a route becomes

Discovery saysThe conversion writesHandoff status
any GET/HEAD route whose whole template graph convertscontent/<url>/index.smd + layouts/<view stem>.shtml + layouts/templates/<layout stem>.shtmlmigrated
redirectnothing in the tree; a redirects[] entry and a RAILS_REDIRECT_HOST_CONFIG finding. to is the URL the action’s own redirect_to names, resolved through the route table (redirect_to about_path/about), or null when this run could not resolve one — and then the row says redirect target is request-time state; set it by handredirect
backend, or any non-GET/HEAD verbnothing in the tree; a RAILS_BACKEND_ENDPOINT question, and an endpoint in the handoff once it is answered (§18)backend
a path with a :param/*glob segmentnothing until a spa decision; then spa/<first segment>.spa.tsxopen, then migrated
a form, a mutating link or the auth journey, answered against --backendthe page, with the region replaced by <island src="…" client:load>, plus the island’s own .tsx, lib/zb.ts, package.json and a --island= flag in build.shmigrated
a route you answered retainnothing — no page, no view file. The page stays on Rails, so this tree must not answer that URLretained
a route you answered blockednothing, for the same reason from the other side: it does not shipblocked
anything the converter could not finish and nobody answeredwhatever it did write, plus the finding ids and a note saying whyopen

The two acknowledged rows are worth dwelling on: emitting a page anyway made blocked a relabelling. The built site served a blank <main> for a route the handoff called blocked, which is worse than a 404 because it looks deliberate. layouts/templates/<layout stem>.shtml is the one exception — shared chrome, written once per layout rather than per route, so it can outlive every page that extended it.

status is the conversion’s verdict and classification is discovery’s, and they disagree on purpose. In this repository’s fixture every pages route classifies unresolved (“the resolved layout reads request-time state”), and GET /about still reports migrated — the fragment that downgraded the classification was a csrf_meta_tags call, which has a defined conversion (dropped). The reverse also holds: a content route whose view holds one unknown helper is open. A consumer asking “did this route migrate” must read status.

URLs → content paths

/content/index.smd; /aboutcontent/about/index.smd; /admin/userscontent/admin/users/index.smd. Always the directory-index form, so /posts and /posts/new cannot collide on a file-versus-directory name. A trailing slash normalises away, so /about and /about/ are one path — and if a Rails app declares both, the second route is reported open with content path collision with GET /about rather than aborting the run.

The static tree serves /about/. Whether /about is a 200 or a redirect is the host’s call, and that difference is not something this converter can hide.

The .smd file

---
.title = "About",
.layout = "pages/about.shtml",
.custom = {
    .rails = {
        .route = "GET /about",
        .controller = "pages",
        .action = "about",
        .source = "app/views/pages/about.html.erb",
    },
},
---

.title comes from content_for :title / provide(:title, …), else the converted page’s first <h1>, else "<controller> <action>", else the route path — in that order. .description is emitted only when the view carries a <meta name="description">. .custom.rails is provenance: it is readable from a layout as $page.custom.rails, and it survives hand edits to either side.

Layouts and views

A converted Rails layout always declares both a head and a main block, synthesising them when the ERB never wrote yield :head or yield. That is not tidiness: SuperHTML fatals in both directions — a block with no matching <super> is an UNBOUND TOP-LEVEL BLOCK, a <super> with no matching block a MISSING TOP-LEVEL BLOCK — so the two sides cannot guess what the other declared. The converted view therefore emits exactly the blocks its layout declares, empty where it has no content_for to fill them.

layouts/templates/marketing.shtml, verbatim except for the <nav> the _nav partial was inlined into (elided at the ):

<!DOCTYPE html>
<html>
  <head id="head">
    <title :text="$page.title"></title>
    <!-- rails: csrf_meta_tags dropped; the ZigBase cookie/CSRF boundary owns this -->
    <link rel="stylesheet" href="$site.asset('stylesheets/application.css').link()">
    <!-- rails: javascript_importmap_tags dropped; @z/runtime replaces the Rails JS entry -->
  <super></head>
  <body class="marketing"><main><div id="main"><super></div></main>
    <footer>Presentation Fixture</footer>
  </body>
</html>

and the view that extends it, in full:

<!-- layouts/pages/about.shtml -->
<extend template="marketing.shtml">
<head id="head"></head>
<div id="main">
<h1>About us</h1><p>Static.</p>
</div>

Two consequences worth knowing before you read a handoff:

  • A <title> holding yield(:title) is replaced wholesale by <title :text="$page.title"></title>, because SuperHTML’s :text needs an empty element. Any other text that shared that element is dropped with an informational note. A yield(:title) anywhere else becomes <ctx :text="$page.title"></ctx>.
  • One view converts once per layout, and the first route owns the file. A view’s bytes depend on the layout it extends, and the target has one layouts/<view stem>.shtml. A second route reaching the same view under a different layout is reported open with view shared across layouts: <a> vs <b> rather than served a page whose <extend> points at the wrong parent.

Partials are expanded inline at every render site: SuperHTML’s <extend>/<super> is inheritance, not composition, so there is no include to emit. That is why only partials with literal locals: convert.

The three markers

Conversion never fails on content. A fragment it cannot express as static HTML becomes a comment with the surrounding markup intact, so a half-convertible template still produces a readable page. There are exactly three markers, and they are a contract the e2e greps for:

MarkerMeaning
<!-- rails:finding <id> --><!-- rails:end -->A region with a finding id you answer in MIGRATION.decisions.json. <!-- rails:else --> separates the branches of a control block inside one. Only the outermost finding in a nesting emits a marker — a form holding six fields is one region, not seven — but every inner id still lands in the route’s findings[].
<!-- rails:unmapped <kind> L<line>C<col> -->A defensive hole, not a question: conversion expected a finding at this exact source location and could not find one. Standalone, never paired with an end. Supported conditional failures have stable ids, so seeing this marker indicates finding-derivation drift; it keeps the route open rather than silently reporting a finished page.
<!-- rails: <helper> dropped; <why> -->A helper whose conversion is “delete it” — csrf_meta_tags, csp_meta_tag, the JS-entry family. Informational: it does not keep a route out of migrated, and it also appears in the route’s note so MIGRATION.md records what was removed.

The one drop that is not informational is a content_for :x naming a block the layout does not declare: its body is markup the author wrote and the target does not have, so the route stays open with content_for :x dropped: the layout declares no block with that id.

rails:unmapped is now only openRegion’s defensive backstop. The three routine conditional failures that formerly reached it are ordinary questions: an unbound local is RAILS_LOCAL_UNBOUND, an unresolvable or cyclic partial is RAILS_PARTIAL_UNRESOLVED, and a known route helper whose literal arguments cannot build its URL is RAILS_ROUTE_HELPER_UNRESOLVED. Bound locals and successfully inlined partials derive none of those findings. If an unmapped marker appears on supported input, report it as a converter bug; the route is kept open so the disagreement cannot pass silently.

Assets

An asset is copied when its public URL was derived by a rule that reproduces on every machine — read verbatim out of the app’s own compiled Propshaft .manifest.json or Sprockets manifest-*.json, or, for a public/-rooted file, from the path itself. An asset that is neither is not copied: putting a file in the target under a name nothing references would be worse than the RAILS_ASSET_TRANSFORM finding that says so.

public/assets/** is never copied, even though it is public/-rooted and therefore deterministic. That directory is the pipeline’s compiled output: its manifests (.manifest.json, manifest-*.json) plus a digested copy of every app/assets/ source. The conversion already copies those sources from app/assets/, which is also where both their target path and their Rails URL come from, so copying the compiled directory too would ship each asset twice. Discovery’s assets[] still lists them — the Rails app really does serve them, and the manifest is a record of what discovery found. What a converted site should carry is a conversion question, and it is answered here.

assets[] in the handoff records all three paths per asset:

{ "source": "app/assets/images/logo.png",
  "rails_url": "/assets/logo-abc123.png",
  "target_url": "/images/logo.png" }

rails_url is what Rails served it at (null when the run could not establish one); target_url is what the built site serves it at.

An absolute URL is not an asset. image_tag "https://cdn.example.com/x.png" (and the protocol-relative //cdn.example.com/x.png) names a resource on another host: nothing is copied, nothing is listed in assets[], and no finding is raised. The literal is written into the emitted markup unchanged. Treating it as a local asset that failed to resolve — which is what a bare “does any file match this name” lookup answers — turned every CDN reference in an app into a RAILS_ASSET_TRANSFORM question about a file that was never supposed to exist.

SPAs

A route with a dynamic segment raises RAILS_ROUTE_DYNAMIC_SEGMENT. Answer it spa and the conversion writes one .spa.tsx per first path segment/posts/:id and /posts/:id/edit are two routes of one SPA mounted at /posts, not two SPAs:

// Generated by `zigapagos migrate --from rails`. Every component below is a
// placeholder: the Rails view it names still has to be ported by hand.
import { Router } from "@z/runtime";

export const spa = { base: "/posts" };

function PostsShow() {
  return <p>{"TODO: port GET /posts/:id (posts#show)"}</p>;
}

export const routes = [
  { path: "/:id", component: PostsShow, skeleton: false, staticPaths: [] },
];

export default function App() {
  return <Router base={spa.base} routes={routes} />;
}

and build.sh grows --spa='spa/posts.spa.tsx|/posts', package.json and tsconfig.json appear beside it, and the route’s handoff note reads set dependencies.@z/runtime in package.json — pass --runtime-path on this run and that is done for you (§13). The base is restated on the command line rather than left implicit because src/spa.zig skips both of its cross-checks — that the file’s own spa.base agrees with the command line, and that two SPAs are not mounted inside one another — on a spec with no declared base.

spa is only carried out when the route’s first segment is static. get "/:slug" has :slug as its first segment; honouring the decision there would mean a file called spa/:slug.spa.tsx mounted at a pattern rather than a path, which nothing downstream can build. Such a route stays open with spa needs a static first segment.

15. Decisions

MIGRATION.decisions.json is your answers to the findings. Schema zigapagos.rails-decisions/1:

{
  "schema": "zigapagos.rails-decisions/1",
  "decisions": [
    {
      "id": "RAILS_HELPER_UNKNOWN.app/views/pages/help%2Ehtml%2Eerb.L1C18",
      "choice": "retain",
      "rationale": "number_to_currency is copy on a help page; the literal is fine"
    }
  ]
}
  • id is a findings[].id, verbatim. It is stable across a reworded message and across edits elsewhere in the same file.
  • choice must be one of that finding’s own choices — read the array on the finding, not a remembered list per code (§11).
  • rationale is required and must be non-blank. The file outlives the run and is read by the next person to touch the migration; an unexplained blocked is a decision nobody can revisit.
  • artifact is optional, and required only for a finding whose requires_artifact is trueRAILS_AUTH_JOURNEY, and only when the choice is one that produces something (island). "" is normalised to absent, so it cannot satisfy the requirement.

The file is read from DIR/MIGRATION.decisions.json when it exists, or from --decisions FILE when you name one. The default is existence-gated, not read-gated: the first run of every migration has no answers file, and that is the normal state, not a failure.

What each choice does

ChoiceEffect
retainstatus retained — you are keeping the Rails behaviour as it is. Accounts for the route, and writes no page: Rails still serves that URL, so this target must not.
blockedstatus blocked — the converter cannot produce this and you have acknowledged it. Accounts for the route only with a decision attached, which by construction it always has. Writes no page: a blocked route that still emitted its converted page would serve a blank one, which is worse than a 404 because it looks deliberate.
spaon RAILS_ROUTE_DYNAMIC_SEGMENT with a static first segment, scaffolds the .spa.tsx and reports migrated.
an operation id, or custom:/<path>on RAILS_BACKEND_ENDPOINT only: binds the form, the mutating link or the route to that ZigBase operation, writes the island, and reports migrated (or fills a backend route’s endpoint). Needs --backend§18.
island on RAILS_AUTH_JOURNEYwith an artifact naming an auth collection, scaffolds AuthForm/AuthStatus, lib/zb.ts and the journey’s three endpoints, and settles every route the journey rides on.
island on the errors / current_user shape of RAILS_REQUEST_TIME_STATEmounts the island that replaces that region — the bound form’s error list, or AuthStatus.
island or backend on a portable ivar RAILS_REQUEST_TIME_STATEwrites a data island that reads the inferred collection through lib/zb.ts; use optional artifact to select another backend collection. On a dynamic route this is the second answer applied after its spa decision, so the generated SPA gets the record-backed view component.
island on RAILS_STIMULUS_CONTROLLERwraps the element in one structural island per controller; targets, values, classes and actions are wired, while original method bodies remain quoted TODOs.
dropon RAILS_STIMULUS_CONTROLLER, removes that element extent’s Stimulus controller/action/target/value/class data attributes and leaves the markup. On RAILS_JS_ENTRY, records that the listed entry was reviewed and omits it because the generated runtime and islands replace it.
island on RAILS_TURBO_FRAMEwraps the frame body in the shared fetching island.
inlineon a closed source-less RAILS_TURBO_FRAME, preserves the frame markup without Turbo navigation.
island on RAILS_COMPONENT_ROOTcopies the React source closure and mounts it through the compatibility bridge with literal props.
publicon RAILS_ROUTE_AUTH_GUARD only: ships the page and records that you decided to, with the note guarded by before_action :<name>; shipped public by decision — the same filter the finding’s own message named, which is the smallest (name, line) among the auth-looking filters the action runs, so a controller with two of them names one filter and not two. It does not change the route’s status by itself — it settles that one finding, and the ZigBase rule on each operation is what actually protects the data.
island (anything else)accepted only where that finding’s own choices offer it; unsupported interactive shapes do not offer the word.
backend on an unportable regionnot offered. The choice appears only when the same body() port that emits the data island has proved it can follow the region.

When a route has several open findings and you answered more than one, the strongest answer decides its status: blocked beats retain, both beat an answer that produces something (an operation id, island, public), and that in turn beats a deferral (backend); ties break on the smaller finding id. An operator who blocked a route on one of its gaps has not agreed to ship it because another gap was marked retain.

Every answer on the route is carried out, strongest first — each answered finding is settled by its own choice, and the status above is the strongest outcome among them rather than the verdict of a single winning answer. So a route carrying both a bound mutation and an answered RAILS_ROUTE_AUTH_GUARD comes back migrated and carries the guard’s note (guarded by before_action :require_login; shipped public by decision): the binding wrote the island, and the public settled the guard. Answering that same guard retain instead comes back retained, because retain outranks the binding.

retain and blocked are the exception, and they stop the walk. They acknowledge the route — it stays on Rails, or does not ship at all — which moots every answer about what is on that page, so once one of them has been applied nothing after it runs. Carrying on would not change the status (that is what makes them the top two ranks, and why one can only ever be the first answer applied) but it would file notes about work that is not happening: public’s note says a guarded page is shipping, and on a retained route no page ships.

Three arms sit outside this rule, and each applies exactly one answer. A dynamic route looks up the answer to the RAILS_ROUTE_DYNAMIC_SEGMENT id it has just raised rather than choosing among the route’s findings. A redirect route and a backend route each record the strongest answer — a backend route that bound an endpoint also settles it; a redirect route only records it — but never let it change the route’s status — redirect and backend are already complete answers about what the route is, and a retain on either would claim a page decision nobody made.

The answer is applied before anything else can veto it. A route whose view the converter refused outright (a parse error, an unsupported engine) is settled by a retain/blocked answer. A backend answer, or an island on a code no converter owns, still leaves the route open and the note names the deferral. A defensive rails:unmapped marker likewise keeps an otherwise unanswered route open, but supported conversion failures now have finding ids and should not reach that backstop.

Answering a Haml or Slim route

RAILS_TEMPLATE_ENGINE_UNSUPPORTED is how such a route is closed. Its id ends in .engine rather than a line/column, because nothing parsed the file:

{
  "id": "RAILS_TEMPLATE_ENGINE_UNSUPPORTED.app/views/posts/legacy%2Ehtml%2Ehaml.engine",
  "choice": "blocked",
  "rationale": "legacy.html.haml is Haml; no converter reads that engine, so /posts/legacy is blocked rather than shipped empty."
}

retain and blocked are the only two choices, and neither produces a page: the route reports retained or blocked, never migrated. migrated is not a choice anywhere in the vocabulary, which is what stops anyone declaring one of these routes converted.

A Haml or Slim layout is the same question asked once for a whole controller. Every route that declares it carries that layout’s RAILS_TEMPLATE_ENGINE_UNSUPPORTED id in its own findings[], so one entry settles all of them at once. While it is unanswered those routes still emit their page — standalone, without the chrome the layout would have supplied — and say so in note.

Answering a route with no view template

RAILS_NO_TEMPLATE is the route-scoped counterpart: the controller action exists and the route resolves, but nothing under app/views/<controller>/ matches the action name. def other; render :about; end is the usual cause — this stage does not follow a controller’s render, so it sees an action with no template of its own. The id is keyed on the routes.rb line, like the dynamic-segment and redirect rows:

{
  "id": "RAILS_NO_TEMPLATE.config/routes%2Erb.L56",
  "choice": "retain",
  "rationale": "pages#other renders the about template; leave /other on Rails until the action is split."
}

retain/blocked only, for the same reason: there is no template, so no answer this stage can carry out produces a page.

Validation

Every offending entry is reported, not just the first — a hand-written file usually has more than one fault, and re-running once per complaint is the failure mode this avoids. Exit 1, not 3: the file you wrote is wrong, which is a failure of this invocation, not an unfinished migration.

error: DIR/MIGRATION.decisions.json is not a usable decisions file:
  entry 0 (`RAILS_I18N_UNRESOLVED.app/views/pages/help%2Ehtml%2Eerb.L1C62`): choice "island" is not offered for "RAILS_I18N_UNRESOLVED.app/views/pages/help%2Ehtml%2Eerb.L1C62"; allowed: retain, blocked
  entry 1 (`RAILS_RAW_OUTPUT.app/views/pages/help%2Ehtml%2Eerb.L1C47`): decision "RAILS_RAW_OUTPUT.app/views/pages/help%2Ehtml%2Eerb.L1C47" has an empty `rationale`; say why, the next reader cannot ask
  entry 2 (`RAILS_RAW_OUTPUT.app/views/pages/help%2Ehtml%2Eerb.L1C47`): duplicate decision for id "RAILS_RAW_OUTPUT.app/views/pages/help%2Ehtml%2Eerb.L1C47"; each finding may be answered once

A wrong or missing schema marker is its own complaint, and is not best-effort parsed:

error: DIR/MIGRATION.decisions.json is not a usable decisions file:
  schema is "zigapagos.rails-decisions/2", expected "zigapagos.rails-decisions/1"

so is a misspelled key (unrecognized key "reason" in a decision entry; expected id/choice/rationale/artifact) — a typo that a default would otherwise hide by silently leaving the real field empty.

An unknown id is not an error — it is stale

There is no separate “unknown id” failure. An id that matches no finding in this run — whether you mistyped it or its finding really is gone — is reported as stale, not rejected: one RAILS_DECISION_STALE blocker (warn, integrity: false) per entry, in MIGRATION.md and the manifest, with the run’s exit code unaffected by it. Nothing can tell a typo from an answer whose finding was fixed since, and refusing to run would strand an operator whose only sin is a tidied-up template.

- `RAILS_DECISION_STALE` MIGRATION.decisions.json: decision for `RAILS_HELPER_UNKNOWN.app/views/pages/gone%2Ehtml%2Eerb.L1C1` answers no finding in this run; delete it or re-check the id

The reason is the loop itself. Finding ids are derived from the template’s own text and location, so fixing the template you were asked about — deleting the helper call, adding the missing translation — is exactly what makes your recorded answer’s id disappear. Failing there would punish the remedy. A stale entry is still held to the id/duplicate/rationale rules: it may well be revived by an edit, so it should be well-formed even while it applies to nothing.

Note the blocker’s path is always the file’s basename — whatever you passed to --decisions, absolute or relative, and whatever directory you ran from. A relative path encodes where the operator stood just as an absolute one does, and this string lands in MIGRATION.manifest.json: two operators running the same command from different checkouts have to produce the same manifest bytes. The stderr advice is not an artifact, so it prints the real path you gave.

16. The handoff

DIR/MIGRATION.handoff.json, schema zigapagos.rails-handoff/1, described by contract/rails-handoff.v1.schema.json (generated from src/cli/rails/handoff.zig‘s own Zig types, like the discovery manifest’s schema — not hand-maintained). It is a separate, separately-versioned artifact from zigapagos.rails-presentation/1: the manifest records what discovery established about the Rails app and does not change when the converter improves; the handoff records what the conversion produced and is expected to change on every run of the loop.

{
  "schema": "zigapagos.rails-handoff/1",
  "schema_version": 1,
  "generator": { "tool": "zigapagos", "version": "<the build that wrote it>" },
  "backend": { "file": "openapi.json", "contract_version": "1.0.0" },
  "complete": false,
  "routes": [
    {
      "route_id": "GET /about",
      "status": "migrated",
      "artifacts": [
        "content/about/index.smd",
        "layouts/pages/about.shtml",
        "layouts/templates/marketing.shtml"
      ],
      "endpoint": null,
      "decision": null,
      "findings": [],
      "note": "csrf_meta_tags dropped; javascript_importmap_tags dropped"
    },
    {
      "route_id": "GET /posts/:id",
      "status": "open",
      "artifacts": [],
      "endpoint": null,
      "decision": null,
      "findings": ["RAILS_ROUTE_DYNAMIC_SEGMENT.config/routes%2Erb.L14"],
      "note": "dynamic route segment: undecided"
    },
    {
      "route_id": "GET /posts/legacy",
      "status": "open",
      "artifacts": [],
      "endpoint": null,
      "decision": null,
      "findings": ["RAILS_TEMPLATE_ENGINE_UNSUPPORTED.app/views/posts/legacy%2Ehtml%2Ehaml.engine"],
      "note": "view app/views/posts/legacy.html.haml was not converted"
    }
  ],
  "assets": [ … ],
  "redirects": [],
  "parity": [
    {
      "id": "navigate:GET /about",
      "kind": "navigate",
      "url": "/about",
      "expect": { "status": 200, "title": "About us", "h1": "About us", "links": ["/"] }
    }
  ]
}
FieldWhat it is
backend{file, contract_version} for the --backend document this run read, else null. file is the basename — a committed artifact must not carry the operator’s directory layout — and contract_version is the document’s x-zigbase-contract-version when it has one, else its info.version.
completeThe completion verdict, recomputed from routes[] rather than supplied — see below.
routes[].route_id"<VERB> <path>". A label, not a unique key, exactly as routes[].id is in the manifest (§6).
routes[].statusOne of migrated, open, blocked, retained, backend, redirect.
routes[].artifactsTarget-relative paths this route produced, sorted. A shared file (a layout, a .spa.tsx) is listed on every route that reaches it, and written once.
routes[].endpoint{operation_id, verb, path} once something bound this route, else null. The verb and path are the document’s, not the Rails route’s: GET /feed answered listPosts records {"listPosts", "GET", "/api/collections/posts/records"}. A custom:/<path> answer records operation_id: "custom" with the Rails verb and the path you named. Three ways it arrives — §18.
routes[].decision{id, choice, rationale} — the answer recorded against one of this route’s open findings, echoed so a reader does not have to cross-reference the decisions file by hand. A redirect route carries it too: the status stays redirect whatever you answered, but the acknowledgement is visible rather than silently ignored.
routes[].findingsThe finding ids this route left open, sorted. This is the join key: these are the ids you answer. Non-empty on a retained/blocked route too — the findings did not go away, they were acknowledged.
routes[].noteFree prose. Carries both the conversion’s informational drops and the reason a route is not finished. Not identity, not parsed by anything.
assets[]{source, rails_url, target_url} per copied asset.
redirects[]{from, to}. to is the target the redirecting action names, resolved through the route table, or null when this run could not resolve one (a redirect_to @post, a redirect_back).
parity[]Typed, deterministic replay evidence for written pages/assets and applied backend endpoints. The seven correlated kind/expect shapes are described in §20. Empty only when this run produced no replayable evidence.

Every list is sorted under a total order, and every tiebreak compares content rather than a row’s position in the producer’s slice. The claim is order-independence, not merely “deterministic for a given input order” — which matters because the decide-then-re-run loop assembles the same facts in different orders.

complete, and the exit code

complete is true iff every route whose verb is GET or HEAD has status migrated or redirect; or retained/blocked with a non-null decision; or backend with a non-null endpoint or a non-null decision.

retained and blocked are the two statuses only an operator can produce (both come from a choice, never from the conversion), and both mean the target serves nothing at that URL. A row carrying either with no decision behind it would be a URL the migration silently stopped answering, so neither counts as accounted without one.

Three things that rule deliberately does not do:

  • Non-GET/HEAD routes are not counted at all. A POST /session is form traffic, and leaving it unconverted does not make the site broken to browse.
  • backend needs its endpoint bound, or acknowledged. It marks a route that needs no page, but it does need an answer to “which operation is this?” — so a backend row counts only once it carries an endpoint or a decision. Because non-GET/HEAD routes are outside the count entirely, this bites on exactly one shape: a user-facing GET that renders JSON, such as the fixture’s GET /feed. Until this stage the status counted unconditionally, which is what made a route the operator had never seen a question about look accounted for.
  • A user-facing route with no row at all counts as open. Absence is the unanswered case, not an exemption — that is what stops a conversion that silently skipped a route from reporting a complete migration.

The exit code, in order:

CodeMeaning
1An integrity: true blocker fired (or, under --strict, any blocker at all). The artifacts were written but cannot be trusted. Checked first: a run that is both broken and incomplete reports broken. Also 1 for an unusable decisions file, a rejected --target, and an unusable --backend document — one whose path cannot be read (error: --backend <path> could not be read: FileNotFound) or that is not an OpenAPI 3.x document with a paths object (error: <path> is not a ZigBase OpenAPI document: InvalidJson). A path the operator typed wrong is their input being wrong, so it prints and exits 1 rather than taking the fatal path, which panics with 134 under a debug build.
3Everything was written and read correctly, and at least one user-facing route is still unanswered. Only reachable with --target: a run with no target has no handoff and therefore no completion question, so it never exits 3.
0Complete — or a -o run with no blockers.

MIGRATION.md renders the same verdict as a ## Handoff section:

## Handoff

complete: false
backend: openapi.json (1.0.0)

`MIGRATION.handoff.json` records what each recovered route became.

| Status | Routes |
| --- | --- |
| migrated | 0 |
| open | 11 |
| blocked | 0 |
| retained | 0 |
| backend | 4 |
| redirect | 1 |

endpoints: 0 of the 4 `backend` route(s) are bound to a ZigBase operation.

Next: each `open` route in `MIGRATION.handoff.json` lists the
finding ids still unanswered. Answer each one in
`MIGRATION.decisions.json` -- `{"id": "<finding id>", "choice":
"<one of that finding's choices>", "rationale": "why"}` -- then
delete everything in the target except that file and re-run the
same command.

It is recomputed from the same rows the JSON’s own complete came from, so the report, the JSON and the exit code cannot disagree.

The two backend lines are emitted unconditionally. backend: none is not a line that disappears: the operator wondering why their RAILS_BACKEND_ENDPOINT findings offered nothing but retain/blocked is precisely the operator who forgot the flag. endpoints: sits under the backend row it refines rather than in the header block, because endpoints: 2 alone does not say whether one is left or three are — and it is the remainder that keeps complete false.

17. The re-run loop

zigapagos migrate app --from rails --target site   # exit 3, N routes open
$EDITOR site/MIGRATION.decisions.json              # answer them
find site -mindepth 1 -maxdepth 1 ! -name MIGRATION.decisions.json -exec rm -rf {} +
zigapagos migrate app --from rails --target site   # repeat until exit 0

The wipe is not optional: every file in the target is exclusive-create, so a second run into a populated directory is rejected. MIGRATION.decisions.json is the one basename the non-empty guard tolerates (§8), which is what lets the loop keep its own state. Keeping the decisions file somewhere else and passing --decisions works equally well, and lets you delete the whole target instead.

Then, on exit 0:

bash site/build.sh

If any of your answers scaffolded JS — a spa, a Stimulus/frame/React island, or any --backend binding — the final migrate needs --runtime-path (or ZIGAPAGOS_RUNTIME_DIR in its environment), or build.sh’s bun install fails on the file:TODO-SET-RUNTIME-PATH placeholder before release runs:

zigapagos migrate app --from rails --target site --runtime-path /path/to/runtime

(Editing that one line of the generated package.json afterwards works just as well — see The generated project builds.)

Pass --backend on every run of the loop, not just the last one. It is what widens the choices an answer has to come from, so a run without it rejects the operation id you wrote last time (allowed: retain, blocked), and a run without it first never shows you the ids to choose among.

The loop, on this repository’s own fixture

tests/migrate/rails-presentation is an eighteen-route Rails app carrying one of nearly every finding the vocabulary can raise, it ships a real zigbase openapi document under backend/openapi.json, and it ships its own MIGRATION.decisions.json — so the two runs below are the whole loop, end to end, and tests/migrate/rails-presentation.sh pins both.

zigapagos migrate tests/migrate/rails-presentation --from rails --target DIR \
  --backend tests/migrate/rails-presentation/backend/openapi.json

Run 1, no decisions file. Exit 3, complete: false, thirteen routes open:

routestatuswhy
GET /, GET /about, GET /linkedopennothing wrong with the page — the shared _nav partial reads current_user and holds a button_to "Sign out", and a layout’s findings ride on every route under it (four ids each)
GET /helpopenRAILS_HELPER_UNKNOWN + RAILS_RAW_OUTPUT + RAILS_I18N_UNRESOLVED, plus the nav’s four
GET /brokenopenRAILS_TEMPLATE_PARSE_ERROR; no page written, so the nav’s ids never reach it
GET /linksopenRAILS_ROUTE_HELPER_UNKNOWN, plus the nav’s four
GET /liveopenan explicitly unsupported Vue root and a portable Turbo stream that still needs an operator decision, plus the nav and JS-entry ids
GET /postsopenRAILS_PARTIAL_DYNAMIC + RAILS_REQUEST_TIME_STATE + RAILS_ROUTE_AUTH_GUARD (a before_action :require_login), plus the nav’s four
GET /posts/:idopenRAILS_ROUTE_DYNAMIC_SEGMENT, undecided
GET /posts/legacyopenRAILS_TEMPLATE_ENGINE_UNSUPPORTED (Haml); no page written
GET /session/new, GET /registration/newopenthe one RAILS_AUTH_JOURNEY id, plus the nav’s four (and, for sign-up, two errors rows)
GET /widgetsopenone portable Stimulus element, a fetching frame, a source-less frame, and a literal-props React root, plus the nav and JS-entry ids
GET /oldredirecta pure redirect_to; complete without a page and without a decision, and redirects[0] already reads {from: "/old", to: "/about"}
GET /feedbackendposts#feed renders JSON. Its own RAILS_BACKEND_ENDPOINT.config/routes%2Erb.L20.GET.posts is unanswered, so under the amended completion rule it is not accounted
POST /session, DELETE /session, POST /registrationbackendnon-GET, and all three carry the journey’s id

The report’s Handoff section reads backend: openapi.json (1.0.0) and endpoints: 0 of the 4. Nothing is bound yet, so run 1’s tree has no lib/, no components/ and no package.json. Drop the --backend flag and the same run still exits 3 — but backend: none, the feed’s choices narrow to retain, blocked, and the journey’s message ends (pass --backend to validate the name) instead of (in --backend: users).

Answer them. The fixture’s file records twenty-five decisions — every open route’s findings, not one per route. Four representative entries, their rationale abridged (the fixture’s are longer and cite the ruling each one turns on):

{
  "id": "RAILS_TEMPLATE_ENGINE_UNSUPPORTED.app/views/posts/legacy%2Ehtml%2Ehaml.engine",
  "choice": "blocked",
  "rationale": "legacy.html.haml is Haml; no converter reads that engine, so /posts/legacy is blocked rather than shipped empty."
},
{
  "id": "RAILS_ROUTE_DYNAMIC_SEGMENT.config/routes%2Erb.L14",
  "choice": "spa",
  "rationale": "/posts/:id is one page per record; a client-routed SPA mounted at /posts is the shape this converter can actually produce."
},
{
  "id": "RAILS_BACKEND_ENDPOINT.config/routes%2Erb.L20.GET.posts",
  "choice": "listPosts",
  "rationale": "GET /feed renders `Post.all` as JSON, which is exactly what the backend document's `listPosts` operation returns."
},
{
  "id": "RAILS_AUTH_JOURNEY.config/routes%2Erb.L38",
  "choice": "island",
  "artifact": "users",
  "rationale": "one question for the whole sign-in/sign-up flow; `users` is the document's only auth collection."
}

The findings that reach no route outcome — two in posts/_post.html.erb, one in posts/show.html.erb, and the controller’s RAILS_LAYOUT_DYNAMIC — are left unanswered on purpose. They are not stale (the run really does raise them) and answering them would change nothing.

The RAILS_BACKEND_ENDPOINT on the nav’s button_to "Sign out" is left unanswered on purpose too, and that one is not cosmetic: the AuthStatus island replaces the whole if current_user region the button sits inside, so the answer to that region settles the button with it, and the fixture records only the answer that does the work. Answering it as well is accepted — the run still exits 0 and complete stays true; the extra answer is settled and the route’s note names both ids, e.g. choice custom:/api/logout on RAILS_BACKEND_ENDPOINT.app/views/shared/_nav%2Ehtml%2Eerb.L5C54 superseded by the island answering RAILS_REQUEST_TIME_STATE.app/views/shared/_nav%2Ehtml%2Eerb.L5C6, which replaced the region it sits in. See §18’s auth-journey section.

Run 2, with that file in the target and the same --backend. Exit 0, complete: true, no open route:

  • /help, /broken, /links, /posts/legacyblocked;
  • /postsmigrated, with its portable @posts region answered backend, components/data/posts_index.island.tsx reading posts, and the auth guard deliberately answered public;
  • /, /about, /linkedmigrated, their nav region replaced by one components/AuthStatus.island.tsx;
  • /session/new, /registration/newmigrated (they were retained before this stage), each mounting the one components/AuthForm.island.tsx with mode="signin" / mode="signup";
  • /posts/:idmigrated, scaffolding spa/posts.spa.tsx and applying its second, record-backed @post answer to the SPA view component, package.json, tsconfig.json and build.sh’s --spa='spa/posts.spa.tsx|/posts';
  • /feed stays backend, now with endpoint = {listPosts, GET, /api/collections/posts/records};
  • POST /session, DELETE /session, POST /registration stay backend with the journey’s three endpoints — authWithPassword, logout, createUsers;
  • /old stays redirect with its decision recorded — the status was already complete, and the answer is echoed rather than acted on.
  • /widgetsmigrated, with a structural Stimulus wrapper, the shared fetching frame island, the source-less frame preserved by inline, and a React compatibility island; /liveblocked, because neither Vue nor Turbo Streams is silently approximated.

The report now says endpoints: 4 of the 4.

GET / is the interesting one: the nav is a complementary pair (<% unless current_user %>…<% end %> then <% if current_user %>…<% end %>), both halves answered island, and AuthStatus renders both branches itself — so the two answers mount it once, and the route’s note names the half that was folded in: app/views/shared/_nav.html.erb:5 `if current_user` folded into the AuthStatus island above it. Mounting it twice would print “Sign in” twice in the built nav.

The nav’s third region — the <%= current_user.email %> inside the if half — is answered too, and it produces a second note on the same routes: app/views/shared/_nav.html.erb:5 `current_user.email` is inside the region the AuthStatus island replaced, so it mounts nothing of its own. Both notes ride on the decisions, not on the page, so both land on the seven routes that mounted the island — /, /about, /linked, /posts, /session/new, /registration/new, and /widgets.

/registration/new carries two more clauses nobody else does. Its two errors regions were answered island and absorbed into the AuthForm the journey answer mounted, so each is settled as superseded and says by what: choice island on RAILS_REQUEST_TIME_STATE.app/views/registrations/new%2Ehtml%2Eerb.L1C4 superseded by the island answering RAILS_AUTH_JOURNEY.config/routes%2Erb.L38, which replaced the region it sits in, and the same for …L1C36. Both ids are named because neither alone is actionable: you know which finding you answered, and what you cannot see from the target is which other answer made yours redundant.

Then build.sh on that target builds a real site — seven pages, the generated islands and the SPA. /posts now has a static shell mounting its data island; the SPA still mounts at that base for record routes, giving posts/_shell.html, posts/routing-manifest.json and posts/.spa. The two sign-in pages SSR their AuthForm with {"mode":"signin"} / {"mode":"signup"} in the data-z-props block, and every page carries one SSR’d AuthStatus. doctor reports 0 errors and 0 warnings across 8 files (the seven pages plus the SPA shell).

The errors regions in registrations/new.html.erb are worth one line: they name @user, which is the model the sign-up form declares, so they are absorbed into the AuthForm island rather than converted at all. That is why the sign-up layout carries no <!-- rails:unmapped local --> from the full_messages.each do |m| block it used to — one instance of issue #181 closed by the binding rather than by the decision plumbing.

Defensive unmapped markers

Every supported unfinished shape is answerable. In addition to parse, unsupported-engine, unread-template, and no-template findings, issue #181’s three context-sensitive gaps now derive RAILS_LOCAL_UNBOUND, RAILS_PARTIAL_UNRESOLVED, or RAILS_ROUTE_HELPER_UNRESOLVED. A bound partial local still derives nothing, because the render-site literal is substituted.

The id-less rails:unmapped marker remains as a safety net for discovery and conversion vocabulary drift. If it appears, the route stays open and its note says finding derivation drift; report that output as a bug rather than trying to invent a decision id.

18. The backend boundary

Everything above converts presentation. This section is the other half: what happens to the parts of a Rails app that were never presentation — a form’s POST, a sign-out button, a JSON endpoint, the whole sign-in flow — once you tell the migration which backend they should talk to instead.

zigapagos migrate app --from rails --target site --backend openapi.json

The document

--backend FILE reads the OpenAPI document zigbase openapi writes. It is not generated by this tool and has no default location: a document the operator did not name is a document the run does not have. Producing one is three commands against a ZigBase data directory —

zigbase migrate       --data-dir "$d"                  # create the database
zigbase schema apply  schema.json --data-dir "$d"      # the collections
zigbase openapi       --data-dir "$d" --api-version 1.0.0 --out openapi.json

— and this repository’s tests/migrate/rails-presentation/backend/README records exactly that for the checked-in fixture document, with the schema.json beside it so the artifact is reproducible. (There is no zigbase collection create subcommand; the declarative schema apply path is the equivalent.)

What the reader takes from the document, and nothing else:

What it readsWhat it does with it
openapimust start with 3. — a Swagger 2.0 file is rejected, not best-effort parsed
paths × verbsevery operation with an operationId; one without cannot be named in an answer, so it is dropped
x-zigbase-contract-version, else info.versionthe handoff’s backend.contract_version
x-zigbase-access (public/locked/conditional), else x-zigbase-auth (public/authenticated/superuser/path-secret)recorded per operation; an unrecognised value is unknown, never an error — the document is ZigBase’s, not the operator’s
/api/collections/<name>/records[/{id}]the collection an operation belongs to, and its CRUD slot. Any other path is a consumer route
a collection whose create-request schema (resolved one hop through $ref) has both password and passwordConfirman auth collection. This is the only marker there is

The coverage caveat. x-zigbase-coverage.allAuthMethods is always false: auth-with-password and auth-logout are not in the document at all. That is why the auth journey’s session endpoints below are the client’s own method names rather than operation ids — there are no ids for them to be.

RAILS_BACKEND_ENDPOINT: three shapes, one code

shapeidthe resource it ranks by
a form/form_field region<code>.<view>.L<line>C<col>the controller of the route whose main view this template is; null in a partial
a mutating link_to/button_to<code>.<view>.L<line>C<col>Route.controller of the route the link submits to — the same route the binding is paired onto; null when this run resolved no such route
a non-GET or JSON-rendering route<code>.config/routes%2Erb.L<line>.<VERB>.<resource>Route.controller

The route shape is the one exception to the rule that a routes.rb line is one question (§11). resources :posts puts POST /posts, PATCH /posts/:id and DELETE /posts/:id on one line, and one ZigBase operation cannot serve all three; resources :posts, :comments puts two resources on one line, and createPosts cannot serve a comment. So this row alone groups by (line, verb, resource) and carries both in its id — RAILS_BACKEND_ENDPOINT.config/routes%2Erb.L20.GET.posts. The resource component is always emitted (empty when the controller never resolved), so a reader never has to guess whether a trailing token is a verb or a controller.

choices are the document’s own operations, ranked. Operations with the finding’s verb and its collection first, by operation id; then every other operation with that verb, by operation id; then retain, blocked. A different verb is never offered — that is the guarantee that stops an operator binding a DELETE link to createPosts. From the fixture, run 1:

RAILS_BACKEND_ENDPOINT.config/routes%2Erb.L20.GET.posts   listPosts, viewPosts, listUsers, viewUsers, retain, blocked
RAILS_BACKEND_ENDPOINT.app/views/shared/_nav%2Ehtml%2Eerb.L5C54   deletePosts, deleteUsers, retain, blocked

Without --backend both narrow to retain, blocked, and an answer naming an operation is rejected with allowed: retain, blocked.

custom:/<path> is accepted as well, on this code only, and is not enumerated in choices — a free-form token cannot be. It must be custom:/ + an absolute path with no whitespace and no quotes of either kind (a character you need is expressible percent-encoded, e.g. %27); a malformed one says so (choice "custom:x" must be custom:/<absolute path> with no whitespace or quotes). It is how you bind to a consumer route the document does not describe, and it records {"operation_id": "custom", "verb": <the Rails verb>, "path": <what you named>}.

What an answered finding produces

A bound region is not converted — it is replaced:

<island src="components/forms/posts_new.island.tsx" client:load></island>

client:load, not client:visible: the page’s own markup no longer carries the form, so deferring hydration would show markup that does nothing. The island’s path is components/forms/<view stem, '/' → '_'>[_<n>].island.tsx — flattened because release names --island= bundles by basename, and de-collided by an ordinal when two distinct view stems flatten to one name. A partial two views render is one binding and one file, written once.

Alongside it the target gains lib/zb.ts, a @zigbase/client dependency in package.json, and one --island='…' flag per island in build.sh, sorted by path. All of that is keyed on an island being written: a bound route you then answered retain produces none of it — a settled route writes no page, and therefore no island either.

// lib/zb.ts
import { createClient, LocalAuthStore } from "@zigbase/client";
export const zb = createClient("", { authStore: new LocalAuthStore() });

authCollection: "<name>" is added to those options when — and only when — an auth scaffold reached the target, because that option arms the client’s own 401 refresh and arming it for a site that never signs anybody in would be a claim about the site that is not true.

Which call the island makes is read off the verb and the collection, because the document is the authority on both:

collectionverbcall
a collectionPOSTzb.collection(c).create(values)
a collectionPATCH/PUTzb.collection(c).update(values.id, values)
a collectionDELETEzb.collection(c).delete(values.id)
none (a consumer route, or custom:)anyzb.send("<VERB>", "<path>", { body: values })

An update or delete with no record id renders TODO: this form acts on one record; pass its id and nothing else — one message for both shapes, since both address a single record and a static page has no request to read its id from. A form whose submit can only fail at runtime is worse than an honest stub.

A generated form island, verbatim (form_with(model: @post) with one text_field and a submit, answered createPosts, on a posts#create that redirects to posts_path):

// Generated by `zigapagos migrate --from rails` from app/views/posts/new.html.erb:2.
// Replaces: form_with(model: @post) do |f|
// Enforcement stays server-side: this island only presents the form and the backend's
// validation errors; the ZigBase rule on the operation decides who may submit.
import { useState } from "@z/runtime";
import { isZigbaseError, type FieldError } from "@zigbase/client";
import { zb } from "../../lib/zb";

export interface Props {}

export default function PostsNew(_props: Props) {
  const [values, setValues] = useState<Record<string, string>>({});
  const [errors, setErrors] = useState<Record<string, FieldError>>({});
  const [done, setDone] = useState(false);
  const set = (field: string) => (e: any) =>
    setValues({ ...values, [field]: String(e.currentTarget.value ?? "") });
  async function onSubmit(e: any) {
    e.preventDefault();
    setErrors({});
    try {
      await zb.collection("posts").create(values);
      location.assign("/posts");
    } catch (err) {
      if (isZigbaseError(err)) {
        setErrors(err.data);
        return;
      }
      throw err;
    }
  }
  const errorList = (
    <ul class="errors">
      {Object.entries(errors).map(([f, e]) => (
        <li key={f}>{f + ": " + e.message}</li>
      ))}
    </ul>
  );
  if (done) return <p>{"Done."}</p>;
  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="title">{"Title"}</label>
      <input id="title" type="text" name="title" value={values["title"] ?? ""} onInput={set("title")} />
      {errorList}
      <button type="submit">{"Create"}</button>
    </form>
  );
}

Three things in there are decisions, not incidentals:

  • location.assign("/posts") is where the Rails action went. The redirect comes from the paired action’s own redirect_to, resolved through the route table: C#new pairs with C#create and C#edit with C#update, and only a non-GET route can receive the pairing. A redirect_to @post resolves to nothing, and then the island calls setDone(true) instead of navigating.
  • The error list sits where the ERB’s full_messages did. A summary naming the form’s own model, or any summary in a template whose bound form declares no model, is absorbed into the island rather than left as its own region — which is one instance of issue #181 closed by construction.
  • The header comment is not decoration. Nothing else ties the generated file to the ERB it replaced, and // Replaces: carries the original call (collapsed to one line, capped at 160 bytes).

The endpoint half

An answered finding fills routes[].endpoint on the route Rails paired with it. Three ways one arrives:

  1. a route-level answer fills its own route — and, because one finding speaks for a whole group, every route sharing that routes.rb line, verb and resource;
  2. a form answer fills the non-GET route its new/edit action pairs with, never the page route it sits on;
  3. a link answer fills the route the link submits to — matched by (helper name, verb), or (literal path, verb) — which the link names outright, so it is not a guess.

endpoint‘s verb and path are the document’s, not Rails’: GET /feed answered listPosts records {"listPosts", "GET", "/api/collections/posts/records"}.

And that endpoint is what the amended completion rule reads (§16): a backend row counts as accounted only with a non-null endpoint or a non-null decision. Since non-GET routes are outside the count entirely, this bites on exactly one shape — a user-facing GET that renders JSON. Delete the fixture’s GET /feed answer and the otherwise-complete run drops back to exit 3, while POST /session beside it stays exempt.

A link_to/button_to “performs a mutation” when its effective verb is not GET: an explicit method:, a Turbo data-turbo-method, the rails-ujs data-method, or a bare button_to (whose Rails default is POST). An explicit get on any of them answers “not a mutation” and the node stays an ordinary <a href>.

Unanswered, such a link is an empty region, not a link.

<!-- rails:finding RAILS_BACKEND_ENDPOINT.app/views/pages/home%2Ehtml%2Eerb.L2C5 --><!-- rails:end -->

That matters more than it looks: before this stage the same node converted to <a href="/logout" method="delete">, an ordinary GET link pointing at a DELETE route, on a page the handoff called migrated with nothing open. Now the page stays open on the link’s own finding until somebody answers it.

Answered, it is a click island — same naming and de-collision rules as a form island, a <button type="button"> instead of a <form>:

// Generated by `zigapagos migrate --from rails` from app/views/pages/home.html.erb:2.
// Replaces: button_to "Sign out", logout_path, method: :delete, data: { turbo_confirm: "Sign out?" }
// Enforcement stays server-side: this island only presents the control and the
// backend's errors; the ZigBase rule on the operation decides who may submit.

  async function onClick() {
    if (!window.confirm("Sign out?")) return;
    setErrors({});
    try {
      await zb.send("DELETE", "/api/collections/users/auth-logout");
      location.assign("/about");
    } catch (err) {  }
  }

The redirect is read off the mutating action (pages#logout’s own redirect_to), never off the page the button sits on. post_path(1) gives the record id for a delete("1")/update("1", {}) call; without one the island renders the same honest TODO a form does.

Nested data: hashes are flattened the way Rails flattens them. The sidecar expands data:/aria: hashes of scalar literals into data-<dasherised key> attributes in the hash’s own position, and button_to‘s form: { data: … } onto the control (Turbo reads the form’s data-turbo-confirm exactly as the submitter’s). So both the Turbo spelling (data: { turbo_method: :delete }) and the rails-ujs one (data: { method: :delete }) raise the finding, and both confirm spellings (data: { turbo_confirm: "…" }, data: { confirm: "…" }) reach the island’s window.confirm guard. Only scalar literals flatten: data: { params: { a: 1 } } or data: { confirm: @msg } is left as written, and the call stays a route_helper_dynamic node.

The attribute NAME goes through the same two filters ActionView applies. A blank key renders nothing at all (data: { "" => 1, ok: 2 } is just data-ok="2"), and a name XML forbids is escaped the way ERB::Util.xml_name_escape escapes it — every offending character becomes _, so data: { "with space" => "v" } is data-with_space="v", not two attributes the second of which has no name.

The auth journey

Sign-in and sign-up are one question, RAILS_AUTH_JOURNEY, keyed on the smallest config/routes.rb line any journey route was declared on, because the answer to both is a single ZigBase auth collection.

A route is a journey route when its controller is sessions or registrations, or when its own reachable view holds a form containing a password_field. Journey views — and, through the render graph, the partials they render, to the same depth Stage 1’s partial walk uses — raise no RAILS_BACKEND_ENDPOINT of their own: the journey finding is their question, and asking twice about one form would let one form get two conflicting answers. A partial reached from a journey view and an ordinary one is the journey’s.

The message names every route it covers and, when the document has auth collections to name, which (one line, wrapped here):

auth journey: DELETE /session, GET /session/new, POST /session, GET /registration/new,
POST /registration; island needs artifact = the ZigBase auth collection name
(in --backend: users)

Without candidates to name, the tail is (pass --backend to validate the name) instead. Answer it:

{ "id": "RAILS_AUTH_JOURNEY.config/routes%2Erb.L38",
  "choice": "island",
  "artifact": "users",
  "rationale": "…" }

artifact is required for island and validated: a name that is not an auth collection of the --backend document is refused — artifact "members" is not an auth collection in the backend document; auth collections: users. With no document the name is accepted verbatim, because there is nothing to check it against.

That one answer produces four things.

components/AuthForm.island.tsx — one file, both halves, told apart by a mode prop. One finding got one answer, so one component carries it; the generated file’s own header says the same thing, citing the design assumption (A5) that folded the two forms together:

<island src="components/AuthForm.island.tsx" client:load :props='{ .mode = "signin" }'></island>
<island src="components/AuthForm.island.tsx" client:load :props='{ .mode = "signup" }'></island>
const signup = props.mode === "signup";

if (signup) {
  await zb.collection("users").create({ email, password, passwordConfirm });
}
await zb.collection("users").authWithPassword(email, password);
location.assign(signup ? "/" : "/");

The two redirects are the two Rails actions’ own, resolved through the route table: this fixture’s sessions#create and registrations#create both redirect_to root_path, so both arms read "/"; an app whose sign-up lands on the sign-in page emits signup ? "/session/new" : "/". The sign-up branch renders the confirmation field; the sign-in branch does not. Because the component’s bytes must not depend on which view was converted first, AuthForm carries no from <path>:<line> header.

components/AuthStatus.island.tsx replaces a current_user region — the <% if current_user %> in a nav, greeting and sign-out button included:

const [ready, setReady] = useState(false);
useEffect(() => setReady(true), []);
async function logout() {
  await zb.collection("users").logout();
  location.reload();
}
if (!ready || !zb.authStore.isValid) {
  return <a href="/session/new">{"Sign in"}</a>;
}
return (
  <span>
    {String(zb.authStore.record?.email ?? "")}{" "}
    <button onClick={logout}>{"Sign out"}</button>
  </span>
);

The ready flag is deliberate. The session lives in the visitor’s own browser, so the prerendered HTML cannot know who is signed in: the SSR’d markup is the signed-out branch and the effect flips it. Reading the store during the first render would make the server’s markup and the client’s disagree. AuthStatus is mounted by answering that region’s own RAILS_REQUEST_TIME_STATE finding island, not by the journey answer — the journey supplies the collection, the region supplies the position.

Two rules govern how many times it mounts.

  • The complementary-pair rule. <% if current_user %>…<% end %> beside <% unless current_user %>…<% end %> in one template is one control, not two: AuthStatus renders both branches itself, so mounting it twice would print the nav’s “Sign in” twice in the built page. Answered, the pair is one mount at the first region and the second is absorbed, with the route’s note naming the half that was folded: app/views/shared/_nav.html.erb:5 `if current_user` folded into the AuthStatus island above it. if X + if !X and unless !X pair the same way. Two regions of the same polarity (a nav greeting and a footer CTA), two regions on different predicates (current_user vs signed_in?), and the same pair in two different templates are all two controls and mount twice — a duplicate is a visible blemish, a wrong pairing silently deletes a control, so every uncertain case falls to two mounts. An <% if … else … end %> was always one region and stays one mount.
  • The sign-in-link rule. The <a href="/session/new"> is the journey’s own sessions#new route path. A journey detected only by a password_field — no sessions controller at all — has no such route, and then the component renders no link rather than one to a URL that does not exist.

A finding inside the region the island replaced is settled, not refused. The nav’s if current_user region holds two more findings — a RAILS_REQUEST_TIME_STATE on <%= current_user.email %> and a RAILS_BACKEND_ENDPOINT on the button_to "Sign out" — and mounting AuthStatus over the region answers both by construction. Two things follow, and neither of them is a refusal:

  • An answer on one of them is accepted and settled. The run does not exit 3, and the route’s note records what happened, naming both ids: choice custom:/api/logout on RAILS_BACKEND_ENDPOINT.app/views/shared/_nav%2Ehtml%2Eerb.L5C54 superseded by the island answering RAILS_REQUEST_TIME_STATE.app/views/shared/_nav%2Ehtml%2Eerb.L5C6, which replaced the region it sits in. Rejecting it would be misleading — the operator answered a real question with one of its own choices — and dropping it silently would leave them unable to tell which of their two answers took effect.
  • An answered status region nested inside another is additionally marked enclosed. The island’s header lists it among the regions it replaces, with its position spelled out — // app/views/shared/_nav.html.erb:5 -- current_user.email (inside the region above, which this replaces) — and the route note says the same in prose: app/views/shared/_nav.html.erb:5 `current_user.email` is inside the region the AuthStatus island replaced, so it mounts nothing of its own. That is the difference between an answer this stage carried out and one it merely recorded: the region is genuinely gone from the page, so there is nothing left for a second island to mount.

Both notes ride on the decisions, not on the page, so they land on the routes whose answers mounted the island and on no others. On the fixture the fold note and enclosed note appear on exactly seven routes: /, /about, /linked, /posts, /session/new, /registration/new, and /widgets. GET /posts/:id resolves no layout at all (layout :choose is dynamic), so it never reaches the nav and its note is null.

The supersession clause in the first bullet is rarer than either. On this fixture it lands on /registration/new alone, twice — once for each of the sign-up view’s two errors regions, which the journey’s AuthForm absorbed.

Three endpoints, on the three non-GET journey routes:

routeoperation_idverbpath
POST /session (sessions#create)authWithPasswordPOST/api/collections/users/auth-with-password
DELETE /session (sessions#destroy)logoutPOST/api/collections/users/auth-logout
POST /registration (registrations#create)createUsersPOST/api/collections/users/records

The first two are CollectionService method names, not operation ids — allAuthMethods is always false, so the document carries no ids for them. createUsers is re-derived by ZigBase’s own <verb><Base> rule rather than looked up, so all three come from one rule: a trio where one member came from the document and two were synthesized would disagree with itself the moment the document did.

And lib/zb.ts with authCollection: "users", which is what arms the client’s 401 refresh.

RAILS_ROUTE_AUTH_GUARD and public

A page route whose controller runs a before_action whose symbol name contains login, auth, sign or user raises RAILS_ROUTE_AUTH_GUARD:

page is guarded by before_action :require_login on posts; a static page cannot
enforce it: GET /posts

The filter is looked up along the inheritance chain, not on the route’s own controller alone. The overwhelmingly common Rails shape is one before_action :authenticate_user! on ApplicationController with everything else inheriting it; matching only the route’s own controller found nothing and shipped a guarded page silently public, which is the failure this code exists to prevent. skip_before_action is honoured, and the message names the declaring controller — that is where an operator has to go to read it. When several filters match, the smallest (name, line) is named, so the message does not depend on the order the sidecar’s directory walk emitted them in.

Answer it public and the page ships, with the decision on the record:

guarded by before_action :require_login; shipped public by decision

public is a new choice word and means one specific thing: ship the page; the ZigBase rule on the operation protects the data. It settles that one finding and changes nothing else about the route’s status: a route whose only answer is public still comes back open on whatever else it raised. It does not have to be the route’s only answer, though — every answer on a route is applied (§15), so a route carrying a bound mutation and this guard, answered both, comes back migrated with the note above on it. retain/blocked are the other two answers, with their usual meanings, and either of them ends the route there.

Exit codes

Unchanged for a run whose --backend document is usable. Otherwise:

conditioncodestderr
the path cannot be read (missing, a directory, unreadable, over the 16 MiB cap)1error: --backend <path> could not be read: FileNotFound
it is not an OpenAPI 3.x document with a paths object1error: <path> is not a ZigBase OpenAPI document: InvalidJson (or NotOpenApi3, NoPaths)
--backend on a non-Rails source1error: --backend only applies to Rails sources; Hugo binds nothing to a ZigBase operation
--backend with no argument1error: --backend needs a file path
--doctor alongside it1error: --doctor is mutually exclusive with --target, --decisions, --backend, --scaffold, --convert-content, and --copy-assets

All of them print and return 1 rather than taking the fatal path, which panics with 134 under a debug build — and a debug build is what every shell e2e drives. A path the operator typed wrong is their input being wrong, the same class as an unusable decisions file. The message names the flag as well as the path, because --decisions and --backend fail identically at the OS level and an operator who passed both needs to know which one the kernel refused. The full path is printed, not the basename: stderr advice is not a committed artifact, so the determinism rule that reduces backend.file to a basename does not apply to it.

Known limitations

  • A wrapping interactivity island does not swallow inner findings. The wrapper is emitted and its finding is settled, but conversion still walks its slot; every unanswered inner finding remains open.
  • Custom Stimulus schemas/options remain outside the structural port. Nested controllers may be ported independently. Standard keyboard filters, @window/@document, and prevent, stop, self, capture, once, passive, and !passive are supported; custom mappings/options are refused.
  • The Stimulus and React source scans are lexical. Regex braces and raw text can defeat the structural scan, so those shapes refuse rather than claiming parity. CommonJS require() and dynamic imports are not bundled.
  • Turbo frames keep a proxy boundary. A frame island fetches same-origin HTML from its migrated URL; that URL must still proxy Rails until the new site serves an equivalent fragment. API/JSON routes never receive the frame island.
  • A realtime island dispatches facts, not rendered Rails partials. It emits zigapagos:turbo-stream with the stream, action, target, and record. The receiving DOM renderer must be reviewed before visual parity is claimed.
  • A login partial reached only through the layout is not the journey’s. Journey membership is seeded from route views and widened through the render graph, so a shared/_login_form that only the application layout renders — not any journey route’s own view — keeps its own RAILS_BACKEND_ENDPOINT question instead of being folded into the journey. That is answerable, just answerable twice.
  • An unflattenable data: hash on a bound control is noted, not silently dropped. If a data: hash reaches the converter as a sentinel — which happens when --runtime-path/ZIGAPAGOS_RUNTIME_DIR selects an older sidecar than the one that flattens it — the island is still written and the binding still stands, with a comment after the <island> and a route note: nested data: on a bound control was not recovered; a confirm guard may be missing. Refusing the answer would leave the operator nothing to do but edit the ERB.
  • Layout controls use the same explicit binding decisions as view controls. Layouts and their rendered partials participate in the binding walk, with first-route ownership for shared templates. Layout-only auth forms still retain a separate endpoint question as described above.

19. Interactivity

Stage 4 converts interactive regions only after the operator chooses the artifact. Discovery still classifies an interactive page island; conversion does not infer an answer from that classification.

The Rails sidecar scans literal HTML elements as well as ERB helpers. For an element carrying data-controller, data-vue-component, or a literal <turbo-frame>, it records the opening tag and closes the extent at the matching end tag at the same block depth (B11). Nested same-name elements do not truncate it. If ERB splits the opening/closing syntax or the close cannot be proved, the node is marked missing and no wrapping choice is offered. A wrapping island owns the whole extent, but conversion walks its body normally: an unanswered finding inside the wrapper remains a marker and keeps the route open.

Stimulus

RAILS_STIMULUS_CONTROLLER offers island only when every named controller, action token and element extent is structurally followable. drop is the explicit alternative: it retains the markup while removing data-controller, data-action, and that controller’s target/value/class attributes. Turbo Drive’s data-turbo, data-turbo-action, data-turbo-track, data-turbo-permanent, and data-turbo-prefetch attributes are always dropped; ordinary browser navigation replaces Drive.

This is a structural port, not a JavaScript transpiler (B1). It reads the first exported controller class, its depth-one static targets, values and classes, and ordinary depth-one methods. It skips strings, template literals and comments while balancing braces. Getters, setters, computed keys, static outlets, an unbalanced body, or a non-default-export class refuse the island choice. Regex literals are not lexed, so a brace in a regex also refuses rather than silently dropping behavior. Lifecycle methods are named in the header but not ported. Nested Stimulus controllers can be wrapped independently; the generated helper keeps targets/actions scoped to their owner.

Action descriptors follow [event[.key][@window|@document]->]controller#method[:option…]. The default key schema, modifier combinations, global targets, and standard listener options are wired. Custom schemas/options are refused. With no event, the default is click for links/buttons and input[type=submit], submit for forms, change for selects, input for other inputs/textareas, and permissive click for an unlisted tag.

The fixture’s generated island is byte-for-byte:

// Ported STRUCTURALLY from app/javascript/controllers/reveal_controller.js -- targets, values,
// classes and action bindings are wired; the method bodies are quoted below and NOT translated.
// Behavioural parity is not claimed. Mounted at: app/views/pages/widgets.html.erb:2.
import { useEffect, useRef, type ComponentChildren } from "@z/runtime";
import { bindActions, targetsOf, valuesOf, classesOf } from "../../lib/stimulus";

export interface Props {}

export default function Reveal(props: Props & { children?: ComponentChildren }) {
  const root = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const el = root.current!;
    const targets = targetsOf(el, "reveal", ["details"]);
    const values = valuesOf(el, "reveal", { open: "boolean" });
    const classes = classesOf(el, "reveal", []);
    // toggle -- original:
    //   toggle() {
    //       this.openValue = !this.openValue
    //       this.detailsTarget.classList.toggle("hidden", !this.openValue)
    //     }
    function toggle(event: Event) {
      console.warn("zigapagos: reveal#toggle is not ported");
      // TODO: port the body above using targets, values and classes.
      void event;
    }
    return bindActions(el, "reveal", {toggle});
  }, []);
  return <div ref={root} style="display:contents">{props.children}</div>;
}

Its shared lib/stimulus.ts is embedded from the repository template src/cli/rails/stimulus.ts. It wires the supported Stimulus action grammar, keeps nested controller scopes separate, and returns a cleanup function.

The generated helper exports targetsOf(root, id, names), returning named arrays of target elements within that controller’s scope; valuesOf(root, id, types), reading declared data values (boolean, number, JSON array/object, or string); and classesOf(root, id, names), reading named CSS-class strings. Missing values and classes are omitted. bindActions(root, id, handlers) binds supported action descriptors to the supplied own-property handlers and returns a disposer that removes every registered listener; return it from the island’s effect for teardown. These exports are the shared contract even when this migration spec is installed as a standalone skill without the repository source.

Turbo frames and streams

A frame with a literal or route-helper src offers island only when the URL resolves to migrated HTML rather than API traffic (B3). loading="lazy" uses client:visible; other frames use client:load. A closed frame with no src offers inline and remains ordinary markup. The shared island is:

// Generated by `zigapagos migrate --from rails`.
// The Rails application continues to serve `src`; this island replaces Turbo's browser-side frame navigation.
import { useEffect, useState, type ComponentChildren } from "@z/runtime";

export interface Props { id: string; src: string }

export default function TurboFrame(props: Props & { children?: ComponentChildren }) {
  const [html, setHtml] = useState<string | null>(null);
  useEffect(() => {
    fetch(props.src, { credentials: "same-origin", headers: { Accept: "text/html" } })
      .then((response) => response.text())
      .then((html) => new DOMParser().parseFromString(html, "text/html"))
      .then((doc) => doc.getElementById(props.id) ?? doc.querySelector("main") ?? doc.body)
      .then((el) => setHtml(el.innerHTML))
      .catch(() => { setHtml(null); console.warn("zigapagos: turbo-frame " + props.id + " could not load " + props.src); });
  }, [props.id, props.src]);
  return <div id={props.id}>{html === null ? props.children : <div dangerouslySetInnerHTML={{ __html: html }} />}</div>;
}

The origin rule matters: the migrated host must proxy that src to Rails until it serves an equivalent fragment. The island extracts the matching id, then main, then body; it does not call lib/zb.ts.

turbo_stream_from "posts" and the seven literal-target actions append, prepend, replace, update, remove, before, and after offer island-realtime. An action must sit in a template with one unambiguous literal turbo_stream_from, because its own first argument is a DOM target, not a subscription topic. They mount one shared components/TurboStream.island.tsx with separate literal stream, action, and target props. The island subscribes via withRealtime(createClient(...)), then dispatches a bubbling zigapagos:turbo-stream CustomEvent. A subscription maps ZigBase create, update, and delete events to append, replace, and remove; an explicit turbo_stream.* action keeps the named action.

Reconnect and authorization remain ZigBase responsibilities. Its realtime client reconnects and re-subscribes active topics, authenticates before resubscribing, and the server checks both subscription access and every delivered record. A rejected subscription is exposed on the island’s data-realtime-error attribute and dispatches no record. Client-side hiding is never an authorization boundary.

The event contains a record, not the HTML Rails rendered from a partial. The application must attach a listener that renders that record into the named target; do not mark visual parity complete until that renderer is exercised. A request-time stream/target, an action with no unambiguous subscription, or an unrecognised action therefore keeps only retain/blocked rather than inventing a name or behavior.

React, Vue, and the Rails JavaScript entry

A literal-props react_component offers island only when the named source and its relative import closure can be copied and every bare import is either the React bridge or pinned in the Rails package.json (B9). JavaScript and TypeScript sources are copied unchanged; relative sources under app/javascript/components/ retain that relative path beneath components/react/, while imports outside it go under components/react/_/. The generated tsconfig.json sets allowJs: true so .jsx and .js remain first-class inputs. require() and a dynamic import() refuse the bridge. Non-literal props become RAILS_COMPONENT_PROPS_DYNAMIC, never guessed JSON.

The fixture wrapper is:

// Generated by `zigapagos migrate --from rails` from app/views/pages/widgets.html.erb:8.
// Replaces: react_component("Chart", { series: "a", points: 3 })
// The component is app/javascript/components/Chart.jsx, copied unchanged to components/react/Chart.jsx;
// its `react` imports resolve to the shared runtime through z-runtime.config.json (docs/migration/react-spa-bridge.md).
import Chart from "./react/Chart.jsx";

export interface Props { points: number; series: string }

export default function ChartIsland(props: Props) {
  return <Chart {...props} />;
}

The complete generated bridge configuration is:

{"islandImports":{"firstParty":[],"npmCompat":[]},"resolve":{"react":"@z/runtime/compat","react-dom":"@z/runtime/compat","react-dom/client":"@z/runtime/compat/client","react/jsx-runtime":"@z/runtime/jsx-runtime","react/jsx-dev-runtime":"@z/runtime/jsx-dev-runtime"}}

Vue roots have no compatibility bridge (B5), so discovery emits both the non-integrity RAILS_COMPONENT_VUE_UNSUPPORTED blocker and the answerable finding. RAILS_JS_ENTRY (B6) lists the sole recovered application entry’s imports and offers drop only after review: generated islands and @z/runtime replace the entry; zigapagos never executes it during discovery.

Record-backed data islands

An output/control region on @posts or @post offers island/backend only when body() can port every byte (B7). The inferred singular/plural stem selects a backend collection; optional artifact overrides it and is checked against --backend. List regions use collectionFor/getList; record routes use getOne(params.id). The same rule applies as the second answer on a dynamic route after its spa answer.

The port rules are closed:

Rails nodeEmitted body bytes, or why it cannot follow
text, literal, resolved i18nJS-escaped text; output is HTML-escaped
literal route helper/linkresolved URL and link markup
deterministic assetthe same target URL/tag conversion emits
csrf, importmapdropped
local/ivar matching the record alias, optionally .field/.field?escaped record field (? is removed)
dynamic route helper/link whose arguments are alias fieldspath parameters use encodeURIComponent; link text must be one alias field
if/unless alias.field?a JS conditional across its exact else/end extent
literal/dynamic partial whose locals all map to aliasesrecursively inlined, cycle-guarded
missing translation, unknown/request-state/non-alias ivar, form, errors, rawcannot follow
alias chain deeper than one field (post.author.name)cannot follow
non-literal asset or helper arguments outside alias fieldscannot follow
.present?, .any?, .each, compound/operator predicate, or other controlcannot follow
partial with an unresolved target, cycle, or non-alias localcannot follow
any other nodecannot follow, with its source location and reason

The fixture’s exact ported body() is:

function body(rec: any): string {
  let h = "";
  h += "<article>";
  h += "<a href=\"/posts/" + encodeURIComponent(String(rec.id ?? "")) + "\">" + esc(String(rec.title ?? "")) + "</a>";
  h += "</article>\n<!-- RAILS_TEMPLATE_CONTROL_FLOW fixture: `post` is a template local (bound\n     via `locals:` in index.html.erb, never assigned in THIS file's own\n     source), so Prism sees it as a variable_call and it classifies as\n     `local` -- a GENERIC_PREDICATE per templates.rb's control_info, so the\n     branch itself becomes a plain `control` node instead of an `errors` or\n     ivar one. -->\n";
  h += (rec.published ? "<span>Published</span>" : "");
  h += "\n";
  return h;
}

For the dynamic record route the generated SPA view component is:

function PostsShow() {
  const params = useParams<{ id: string }>();
  const [html, setHtml] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  useEffect(() => {
    zb.collection("posts").getOne(params.id)
      .then((rec) => setHtml(bodyPostsShow(rec)))
      .catch((err) => setError(isZigbaseError(err) ? err.message : String(err)));
  }, [params.id]);
  if (error !== null) return <p>{"Could not load posts: " + error}</p>;
  if (html === null) return <p>{"Loading…"}</p>;
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

Every generated SPA now carries the layout’s deterministic stylesheet links in spa.head; the fixture emits head: [{ rel: "stylesheet", href: "/stylesheets/application.css" }].

Known limits of the interactivity port

  • A finding inside a wrapping Stimulus/frame island remains open; only the wrapper’s own finding is bound.
  • Custom descriptor key schemas and custom action options are not followed.
  • Stimulus/React scans are lexical. Regex literals can defeat brace balance; raw-text elements are deliberately skipped. In particular <pre> content is real DOM but is opaque to the scanner, so a data-action there is not wired even though querySelectorAll can see it. Runtime binding includes the root itself ([root, ...querySelectorAll]) so a root action is not lost.
  • CommonJS require() and dynamic imports are not bundled.
  • A frame src remains a same-origin Rails fragment until the migrated host proxies or replaces that URL; API/JSON routes never get this island.
  • Realtime islands dispatch portable record/action facts; they do not translate a Rails partial into target DOM HTML.

20. Support and parity handoff

The migration is intentionally not a blanket “Rails supported” claim. This matrix is the review boundary for the presentation layer:

Rails presentation surfaceStatusWhat ships
Literal ERB text, headings, links, helpers, layouts, partials and deterministic assetsconverted.smd, .shtml, and copied assets
Conventional forms and mutating controls with a selected OpenAPI operationdecidedA bound form/click island; the ZigBase operation enforces access
Sign-up, sign-in, sign-out and current_user presentationdecidedAuthForm, AuthStatus, and an operator-named auth collection
Portable record-backed ivarsdecidedA data island, or a SPA view after the dynamic route is answered spa
Portable Stimulus controller elementsdecidedStructural target/value/class/action wiring; method bodies remain visible TODOs
Turbo framesdecidedA same-origin fetching island, or source-less inline markup
Portable Turbo StreamsdecidedA ZigBase subscription island dispatching typed stream/action/target/record facts; the application still owns DOM rendering
Dynamic or unsupported Turbo StreamsblockedNo guessed stream name, target, or action; choose retain/blocked
Literal-props React roots with a bundleable source closuredecidedCopied sources plus a compatibility island
Dynamic React props or unsupported source importsblockedNo guessed props or partial bundle
Vue rootsblockedNo compatibility bridge; choose retain/blocked
Reviewed Rails JavaScript entrydecidedExplicit drop; generated islands and @z/runtime replace the entry

Discovery classification is evidence, not the artifact decision:

classificationConversion/handoff meaningPossible target artifact
contentPositive static candidate; findings may still keep it opencontent page + view/layout
islandInteractivity was observed; the operator still chooses its treatmentStimulus/frame/React/data/auth island when the finding proves portable
backendNo static page claim; bind or acknowledge the dynamic routeroutes[].endpoint, sometimes a bound control island
redirectDeterministic redirect when its target resolvesredirects[] host configuration
unresolvedFollow the exact reason/finding; never infer one generic fixconverted artifact, retained, or blocked, depending on the answer
spaDeclared for schema compatibility but never assigned by discoverynone; spa as a dynamic-route decision is a separate producing choice

parity[]: facts a fresh target can replay

parity[] is a tagged union flattened to {id, kind, url, expect}. Its JSON Schema uses correlated oneOf arms, so a kind can never be paired with the wrong expectation payload. Rows and nested lists are sorted and deduplicated. They are emitted only from written migrated artifacts or applied endpoint bindings—never from open, retained, or blocked routes and never by reparsing the generated HTML.

kindReplay and assertion
navigateGET a written static URL; assert status and every statically known title, first h1, and literal link. A null title/heading means source evidence was dynamic, so the runner does not guess or assert it.
assetGET a copied asset; assert status and content type. With RAILS_ORIGIN, rows carrying rails_url also compare the Rails response’s status, MIME and bytes.
signupCreate a runtime-unique account in the answered auth collection; expect 201.
signinAuthenticate those runtime-only credentials and retain the token in memory; expect 200.
submit_deniedReplay an applied non-public operation without a token; expect one of the recorded 401/403 statuses.
submit_allowedReplay the bound form fields with the valid auth-phase token when the operation needs it; expect the recorded 2xx family.
validation_errorBlank the named required field, replay the same operation, and expect 400 plus the browser-rendered field error.

When parity is nonempty the target receives two fixed files, test/parity.ts and test/journey_playwright.py. They contain no route-specific generated logic; both read MIGRATION.handoff.json. The Bun runner covers all seven kinds in phases. The Playwright runner uses system Chrome to prove the rendered signup → signout/signin → allowed submit → validation-error journey. Runtime UUID credentials never enter the handoff.

Build the answered target, initialize an isolated database from the same schema used to produce its OpenAPI document, and serve the release tree with stock ZigBase:

bash DIR/build.sh
d="$(mktemp -d)"
zigbase migrate --data-dir "$d"
zigbase schema apply backend/schema.json --data-dir "$d"
zigapagos e2e --site=DIR/zig-out/site --data-dir="$d" -- bun DIR/test/parity.ts
zigapagos e2e --site=DIR/zig-out/site --data-dir="$d" -- python3 DIR/test/journey_playwright.py

The repository fixture automates that exact release-server path, including a second fresh database, in tests/migrate/rails-presentation-parity.sh. RAILS_ORIGIN is optional and currently an asset oracle only: the fixture is deliberately non-bootable, so Stage 5 does not claim live Rails HTML parity. Nor do these runners replace authorization tests—the browser and its islands are visitor-controlled; ZigBase collection/consumer rules remain the enforcement boundary. A portable Turbo Stream page gets the ordinary navigation replay row, never a claim that its application-specific DOM renderer is equivalent; Vue produces no converted page.

Procedure (for an agent)

  1. Discover. zigapagos migrate <rails-app> --from rails -o MIGRATION.md. Read the exit code: non-zero means at least one integrity: true blocker fired (or, under --strict, any blocker at all) — treat the counts as provisional until that’s resolved (commonly: install/expose Ruby and the sidecar’s dependencies, or fix an unreadable file).
  2. Read the manifest, not just the prose report, for anything driving further automation — it is the binding shape (contract/rails-presentation.v1.schema.json describes it precisely).
  3. Triage by classification. content routes are the only ones safe to treat as static without further review. island routes need their component’s own source read. backend/redirect routes are handoffs to whatever serves the target site’s dynamic behavior. For each unresolved route, read its routes[].reason (or the parenthesized text after unresolved in MIGRATION.md) and look that exact string up in the table in §2 above for the specific follow-up it names.
  4. Read findings[]. Each is a question with a fixed set of choices — see §11 for the field list and §12 for what fragment kind produced it. Read each finding’s own choices array; the same code can offer different lists.
  5. Convert. Re-run with --target DIR, and — if the target site has a ZigBase backend — with --backend <the zigbase openapi document> on this and every later run (§13, §18). Read the exit code: 1 means the run is broken and no amount of deciding will help (an unusable decisions file, a rejected --target, an unreadable or non-OpenAPI --backend document); 3 means it worked and the migration is not finished; 0 means it is.
  6. On exit 3, read DIR/MIGRATION.handoff.json, not the tree. Each status: "open" route lists the finding ids it left open in findings[] and says why in note. Answer every id on every open route. If a route has only a defensive rails:unmapped marker and no id, report the derivation drift described in §17.
  7. Answer them in DIR/MIGRATION.decisions.json (§15), one {id, choice, rationale} per finding. A RAILS_BACKEND_ENDPOINT finding’s choices are the --backend document’s own operation ids (or custom:/<path>); RAILS_AUTH_JOURNEY takes island and an artifact naming the auth collection; RAILS_ROUTE_AUTH_GUARD takes public. Portable Stimulus controllers, Turbo frames, React roots, and record-backed ivars close with their producing island/island-realtime/backend/inline/drop answers; dynamic Turbo Streams and Vue remain retain/blocked rather than approximated.
  8. Delete the generated tree, keeping the decisions file, and re-run with the same flags, --backend included. Repeat until exit 0 and "complete": true, then build the target with bash DIR/build.sh — after pointing @z/runtime somewhere real if the target has a package.json (§13).