Native capability for the Rails target
Status: Normal Compilation independently emits selected iPhone and Android projects under ios/ and
android/. Each receives the Web main navigation: admitted public and protected indexes plus the Account entry. A
realized Account signs in through its generated web forms inside the native shell and restores the session with
Rodauth remember, as browsers do; Native Accounts describes the flow. Launcher
icons derive from the application name. Push and the
placeholder release identity of a Plan without a domain remain precise reviewed
gaps. Tests and CI hold the implemented claims, and the evidence index records the
Revyl, Simulator, and hosted observations.
Hotwire Native is the first Rails-specific approach because it renders the server's HTML inside ordinary iOS and Android applications, uses platform navigation, and permits selective Swift/Kotlin enhancement. See the official Hotwire Native overview and architecture explanation.
This Capability design is a current implementation direction, not the definition of owned native output. A controlled comparison may later favor another client architecture.
Why this is a Capability
The compilation target remains Rails. Native changes how that Rails application is presented, built, notified, and delivered; it does not replace the backend architecture.
The Plan requests each client independently by including an enabled-object fact at native.ios or
native.android; an omitted platform requests no client. Native is therefore neither subject-triggered nor
unconditional. Each fact can later gain bounded platform-specific configuration such as a bundle identifier
without changing the selection shape.
The Capability catalog (repository-only) lists ten subject triggers and none of them can imply a native
client, so waiting for a trigger would mean never emitting one.
Decision 0016 (repository-only) records why the switch is per
platform: a Mac is required to build the iOS project but not the Android one, and the two distribution programs
are separate memberships obtained by different people.
The complete Capability direction would emit:
- Rails-side native detection, authentication, navigation, and push integration;
- a conventional Xcode project;
- a conventional Android/Gradle project;
- Bridge components or fully native screens required by Plan facts;
- build and test workflows;
- root commands and diagnostics; and
- native-specific repository guidance and Compilation Record coverage.
The output may span several directories. That does not make native universal Core or a peer target profile.
Omitting a platform requests none of its project, build and test workflows, path configuration, or Compilation Record coverage. It does not change Web main navigation (repository-only), which derives independently from admitted public and protected Scaffold indexes plus one Account entry. Each selected iOS and Android client lowers that same navigation into native tabs. Account meaning never implies an Apple client or a tab bar.
Each platform lowers independently. A requested client without a usable destination or platform identity is omitted and listed in the reviewed GapSet while a coherent Rails application can still compile. Requesting both platforms does not require both to be lowerable.
Native theme policy
Omitting application.appearance.theme selects fixed light. Explicit light and dark select that appearance in
the native shell and embedded Rails pages. auto follows system appearance. Both native color palettes remain
available for these modes.
The authored toggle choice provides the browser's Light / Dark / System selector and browser-local preference.
Selected native clients are still emitted, but use automatic appearance. Their embedded Rails responses also use
automatic appearance and omit the browser selector and preference storage. A browser preference cannot by itself
control platform window appearance; no native preference control or bridge is generated.
Analysis records this residual once at /application/appearance/theme, with code
foundation_plan.gap.appearance.native_theme_preference.not_generated and status partially_generated. The record
names only the emitted iOS and Android clients. A browser-only Plan or an omitted native client contributes no such
gap. Compilation carries the same reviewed GapSet and preserves the authored toggle in the retained Plan.
The native mappings are covered by emitted-source checks; native runtime switching is not qualified by browser
theme tests.
Placeholder release identity
Many owners have no domain when they start, so a Plan may omit application.domain. A selected client is still
emitted. Without a domain, its Rails origin ends in the reserved .invalid top-level name, which
never resolves, and its bundle or application ID starts with
invalid.firstdraft. The platform sections below give the exact forms. These placeholders cannot reach another
party's server or take a real domain owner's reverse-DNS identifier.
The placeholders also mean the Release build cannot reach the application's server and cannot be submitted to a
store. Analysis therefore records one foundation_plan.gap.native_client.partially_generated entry with status
partially_generated at /application/native/ios or /application/native/android for each emitted client. The
entry names the placeholder origin and ID. It tells the owner to set application.domain, or replace both values,
before a Release build, because a store bundle or application ID cannot change after the app is published. An
authored domain, or a client that is not emitted, adds no entry.
The owner chose this disclosure during the second app-quality audit (repository-only). Selecting a client is authored meaning, and a client that cannot be released delivers that request only in part. The GapSet is the record the authoring agent and the Skill's read-back review most closely, so the limitation belongs there rather than in an emitted constant.
Android application lowering and runtime
AndroidApplication consumes the same target-neutral application navigation as Web and iOS, including protected
indexes and Account self. The versioned
foundation-plan-rails/android-application-2026-09 lowering maps all 28 semantic icons to pinned Material assets.
A domain supplies the HTTPS Rails origin and reversed application-ID prefix. Hyphens become _h; a numeric-leading
label receives d_. DNS labels contain no underscores, so these transforms preserve distinct identities.
The application key remains the final Java package component. Android IDs are capped at the platform's 223-byte
package-name limit. Without a domain, the identity uses the placeholders
invalid.firstdraft.<application_key> and https://<application-key-with-hyphens>.invalid.
AndroidApplicationTaskPlan retains one digest-verified Core archive and its replacement seams: application
properties, generated Kotlin definition and tests, the activity layout, strings, light and dark colors, the launcher
foreground, and bundled path configuration. AndroidRenderingContext binds the exact captured input, its Android and
Appearance projections, the generated form routes, and the unchanged submitted Plan digest. The composer rechecks
seams, owners, modes, and provenance before mounting android/. Unselected or declined Android never loads its Core
archive. The APK's manifest and BuildConfig carry the Plan digest, and generated tests compare it with the Kotlin
definition. Application IDs are separate from the stable Kotlin namespace so an app-specific identifier needs no
source rewrite.
Core uses Hotwire Native Android 1.3.1, Material DayNight views, JDK 17, Kotlin 2.3.0, AGP 8.13.2, and the checksummed Gradle 9.2.0 wrapper. The initial build targets Android 16/API 36 with minimum API 28. That minimum is a build configuration, not evidence that every supported OS/device has been exercised. Native libraries, Google services, Firebase, signing credentials, and Play publication are unnecessary for the generated debug preview.
One destination uses a single Navigator without a tab bar. Two through five use Hotwire's tab controller and separate
back stacks. More than five use four tabs plus a native More list containing every remaining destination. Overflow
routes push from More so Back returns to that list; only direct roots replace their tab's root. Every generated
new and edit route, gated or public, uses a full-screen Hotwire Web destination with context: modal and
pull-to-refresh disabled, matching the pinned Android demo. Rails owns HTML, forms, validation, and server redirects;
Hotwire owns visits, cookies, and history. The Compiler bundles the rules, and the generated Rails app serves the same
file as Hotwire's remote source; Path configuration describes both.
Tab labels remain visible, wrap, and follow system font size. Authored Entity icons supply meaningful tab symbols;
an absent icon retains the semantic grid fallback. Core handles phone rotation in the existing activity, following
the pinned Hotwire demo. The optional bottom-sheet destination remains registered, but generated forms use the
full-screen modal because the nested sheet failed to scroll after validation increased its content height.
Unsaved draft restoration after process death or other activity recreation is separate. Failed regular and modal
page loads expose a Try again button
through Hotwire's public refresh method; no write is queued or automatically retried.
Path configuration selects destinations and modal
presentation; it does not remove HTML inside the WebView. Rails Core uses hotwire_native_app? to omit the web
header and persisted browser theme for native requests, including new/edit forms. The footer's /privacy and
/terms links remain. This applies with or without authored Appearance. Native HTML supplies a concise page title,
falling back to the app name when no title is set.
Generated public Scaffold pages hide repeated app branding and keep their page heading available to screen readers
without displaying it again below the native toolbar. Browser requests keep their header and visible page heading.
Native buttons, inputs, selects, and table links have a three-rem minimum target height; buttons and table links
also have a three-rem minimum width. Ordinary browser sizing remains unchanged. Browsers show the explicit
collection destination; native requests omit that link and rely on client navigation. Omitted theme selects
light; auto and the native toggle residual follow the WebView's reported color preference.
Material themes apply authored automatic/light/dark appearance, accent, and background colors. The native root owns side cutout/system insets and keyboard space, Hotwire owns the top toolbar inset, and visible Material tabs own the bottom system inset. The root consumes the insets it handles. A single destination reserves bottom system space itself; a full-screen modal reserves any remaining bottom inset because Hotwire hides its tab bar. Material's OnSurface toolbar style keeps close/back icons visible in both light and dark mode. Core assigns Back and Close descriptions through Android string resources after Hotwire installs the toolbar icon. The labels preserve Hotwire's navigation behavior; accessibility hierarchy checks do not establish TalkBack speech or focus order. Push remains ungenerated.
Debug accepts APP_ROOT_URL as an Intent string extra, including Android Emulator loopback 10.0.2.2. Release ignores
it and uses the generated HTTPS origin. The Codespaces warning workaround uses Hotwire's public custom-WebView hook
and Android's loadUrl header overload, scoped to the exact configured public Codespaces origin. It does not bypass
private-port authentication. The Core's
upstream comparison (repository-only)
records why no second networking or navigation library is needed and when to retire this small hook.
Initial iOS application lowering
The immutable foundation-plan-rails/ios-application-2026-08 release owns the first pure iOS transforms used by
Compiler composition. It maps all 28 semantic icon tokens to normal and selected SF Symbol names. It
also translates underscores in application.key to hyphens and requires the resulting component to remain one
DNS-safe label of at most 63 ASCII bytes.
A present domain must remain at most 253 ASCII bytes with no label over 63 bytes, must have a final label beginning
with a letter, and cannot use the Compiler-owned .invalid placeholder namespace. The lowering uses it for the
Rails HTTPS origin and reverses its labels for the bundle prefix. Without a domain, it deliberately produces the
placeholders https://<application-key-component>.invalid and
invalid.firstdraft.<application-key-component>.
The same release admits one-line display names and encodes display names and closed HTTPS roots so Xcode preserves
literal dollar signs, double slashes, quotes, Unicode, and edge whitespace. Its xcconfig encoder is nested inside
the versioned lowering boundary; changing those bytes requires a successor release and a fresh Xcode audit.
Display-name character policy remains Plan-wide; this release does not add a separate iOS-only Unicode policy.
The release pins the SF Symbol-name bytes, while runtime resolution at the Core's minimum deployment target remains
unproved until the generated-app Simulator smoke.
The SQL-free IosApplication projection consumes one sealed Project generation and its same-generation
ApplicationNavigation result, the same value Web navigation uses. It retains the Rails route-collision diagnostics
and source identities needed by Web and native consumers. A declined iOS client carries no application output,
navigation entries, diagnostics, task, Core package,
or native provenance output. A selected client lowers the application identity, origin, Xcode scalars, and every
qualified navigation icon. It retains valid entries beside application-navigation or iOS-specific diagnostics, so a
successful result can navigate only to exact Rails routes admitted for Web emission.
Selected iOS reports source-addressed diagnostics when the application key or domain cannot form the pinned iOS
identity, when the display name cannot fit one admitted Xcode configuration line, or when no valid navigation entry
remains after an otherwise successful application-navigation projection. An upstream failure already explains why
no navigation entry survived, so iOS does not restate that failure as a missing-navigation diagnostic. A missing
domain is not an error. The projection marks the resulting .invalid origin and invalid.firstdraft bundle prefix
as a placeholder identity, which the reviewed GapSet discloses.
An inadmissible key or domain leaves the selected result without an application value because no identity can be emitted. An inadmissible display name retains the independently lowered identity and origin but leaves the encoded Xcode display name absent. Consumers proceed only from a selected, successful result.
iOS application renderer
IosRenderingContext binds the exact qualified CompilationInput::Result, its exact successful iOS projection,
its exact ApplicationAppearance projection, the generated form routes, and the lowercase SHA-256 of the exact
Plan snapshot.
CompileCapturedProject serializes that captured Project once, and the active ApplicationManifest coordinator
forwards its digest unchanged. The renderer does not reconstruct provenance from either smaller projection or accept
a reconstructed same-generation value.
Selected iPhone and Android clients cause the complete application README renderer to include their preview-guide links and Rails startup command. The guides retain platform build prerequisites and preview limits. README keeps the private Codespaces port default and routes temporary remote-preview exceptions and cleanup to the selected guide. This is documentation routing, not device or vendor-tool proof. The Core composition owner describes application-document ownership.
The explicit renderer:ios_application family selects no tasks for a declined client and one one-path task per
declared Core replacement for a selected client. Those paths emit application identity and build settings, navigation
definitions, a bundled path configuration, generated unit and UI tests, the AccentColor and FoundationBackground
color-set definitions, and the AppIcon image. The generated definition retains the Project ID, graph version, target,
profile, lowering release, Plan digest, and .automatic, .light, or .dark Appearance theme. Entity-derived files
carry the stable UUIDs of their navigation sources. The former aggregate call remains only as a byte-for-byte
differential test oracle.
Scalar authored colors expand to both modes; pairs become exact sRGB light and dark asset entries. Each omitted color
uses its neutral Core value independently: an empty named accent for missing tint and white/black Foundation
backgrounds for missing background. Absent and theme-only Appearance therefore emit both neutral assets. Omitted
theme selects light and emits INFOPLIST_KEY_UIUserInterfaceStyle; auto and the native toggle residual omit that
value and emit .automatic, so iOS follows the system. Core owns the
launch storyboard's FoundationBackground reference and applies the theme, tint, and background to the window,
Hotwire destination controllers, custom Web views, their scroll views, and their under-page color. The generated
client does not duplicate those runtime surfaces.
The bundled configuration begins with Core's default push and pull-to-refresh behavior. One later, query-safe rule
for each direct tab's root sets presentation: replace_root. With up to five entries every ios_navigation root is
a direct tab, including /account; with more, only the first four are. A realized Account also adds
the session-change rule that Native Accounts describes. The renderer then adds one rule for each
GET form route that the Web Scaffold renderer writes into config/routes.rb: each new and edit resource route,
each associated-create new route below its parent, and /account/edit when the Account is editable, whether an
Account or Policy gates it or not. Each parameter segment matches one path segment, and an optional query string
follows; the rule sets context: modal and pull_to_refresh_enabled: false. Refresh remains enabled for ordinary
pages; form drafts do not acquire a pull-to-refresh action. Other links retain Core's default push behavior. The
renderer does not infer modal routes from navigation strings.
One navigation entry generates tests for Core's ordinary navigation stack. Two through five entries generate tests for every direct tab and label. With six or more entries, the first four entries remain direct tabs and the fifth tab is Core's native More list, as on Android. The generated test opens each listed destination and returns with Back. Every tab loads on first selection, so a cold launch makes one page request. Core does not use UIKit's More controller, which opens only navigators that have already started: every remaining destination would load at launch, in parallel and without a Rails session cookie, and each would create its own CSRF token. A form among them, such as the Account page's Sign out, would then fail its CSRF check (upstream discussion).
A selected client with no entry cannot render. Before any task renders, IosApplicationTaskPlan retains one exact
IosCorePackage, requires
every declared seam with its expected owner and mode, and rejects missing or undeclared seams. The normal
sequential executor then gives a rendering exception or SQL violation the exact file-task identity. The tasks are
independent and need no DAG. IosCoreComposer receives that same retained package and repeats the seam, owner, and
mode checks before composition. Focused Ruby proof composes the manifest with the pinned Core. It requires the
rendered xcconfig settings, path settings, Swift members, asset catalogs, generated-test contracts, and navigation
initializer labels that the pinned fixture and immutable Core source consume, then removes every standalone fixture
marker. The
renderer is selected only when the exact qualified input selects iOS; a browser-only application has no iOS tasks
and never loads the iOS package. Deriving release versions and build numbers remains deferred. No composed generated
application has run through Xcode or a Simulator as part of hosted or Compilation-lifecycle automation. An opt-in
repository harness now repeats one five-Entity generated Debug application against generated Rails through native
tabs. A dated field
report (repository-only) records one
staff-prepared generated application passing its iOS checks and displaying generated Rails pages in an iPhone 17
Pro Simulator.
A second opt-in harness exercises the Movie Catalog Account shape on a copied, instrumented iOS tree. Rails authentication names it.
Core owns a per-launch
APP_ROOT_URL preview seam (repository-only).
Debug accepts an HTTPS host root or HTTP loopback root from the launch environment or -APP_ROOT_URL argument;
Release ignores both and uses the compiled HTTPS origin. The dated Simulator observation used that seam rather than
exercising the emitted production origin.
Core continues to own the window, navigation and tab controllers, transparent scroll-edge appearance, and the safe-area-aware launch storyboard. It also owns Appearance application from the named launch color through runtime window and Web-view backgrounds. Generated Swift and color assets supply data to those seams; they do not draw another header, tab bar, safe-area inset, or Dynamic Island treatment.
iOS Core pin and composition
An adjacent implemented source boundary bundles the current iOS Core
pin (repository-only). Its deterministic archive excludes the entire .github
directory, which currently contains one hosted workflow that would be inert inside generated source. IosCorePackage
verifies the archive through the same byte, revision, path, mode, and collision policy as Rails Core.
IosCoreComposer can mount the source under ios/, preserves Core ownership and modes, and writes
ios/FOUNDATION_PROVENANCE.json without changing artifact-envelope format v1. That file records the source, archive
root and exclusion, mounted path, replaced paths, and generated additions so it describes both the archive recipe and
the deliberately composed tree. The composer treats every file under a Core Generated directory as a declared seam,
rejects an undeclared new seam, and requires every current application identity, navigation, route-configuration,
application-test, accent-color, background-color, and AppIcon path from its exact renderer owner with its Core mode. A
pinned whole-output test also rejects the current fixture's operational identity and generated test markers after
composition. The packaged application requires FoundationApp/Generated/ios_v1.json and passes it to Hotwire Native
before the server URL. The pinned Hotwire Native 1.3.0 loader applies sources in that order: it loads the
bundled file synchronously, then a cached remote value when present, and starts the remote request asynchronously. A
failed or non-200 request returns without replacing the current configuration: the bundled rules on first launch, and
the last fetched copy after that. The generated Rails app answers that request, as
Path configuration describes.
The pinned Core now advertises an iPhone-only device family. Its Simulator tests exercise the real
single-navigator and native-tab containers against a Core-owned sentinel document. UIKit and Hotwire Native own
the native safe-area adjustment, so composed Rails output must retain ordinary viewport behavior instead of
adding generic viewport-fit=cover plus env(safe-area-inset-*) padding. Those tests constrain Core; a composed
Rails smoke remains necessary. iPad qualification is deliberately
deferred in iOS Core (repository-only) until the navigation and
safe-layout contract is designed and exercised there.
IosApplication consumes the exact same-generation iOS navigation result. It maps semantic icon tokens to the pinned
SF Symbols only after iOS selection. Account self may be the sole entry, which produces a single Navigator. Its
entries keep the target-neutral stable identity, route, label, rank, and source provenance. The task
family reads the sealed Project only to prove the exact context generation and performs no SQL. The independent
package pin belongs to the Compiler's provisional native Capability rather than the Rails naming profile.
Release-version and build-number derivation remain later work.
Appearance is consumed by Rails and selected native shells. iPhone and Android lower independently; delivery is pruned at import. With no iOS selection, browser application navigation remains Web input while iOS declines without output.
Launcher icons
Selected iPhone and Android clients reuse the monogram that the application name selects for the Web bookmark icons. The Plan owner describes that selection and its neutral fallback. The artwork is black on white whatever Appearance requests, so the reviewed GapSet carries no icon record. Custom icon fonts and colors remain deferred (repository-only).
script/build_bookmark_icons.mjs draws every monogram once, and the Compiler copies the finished files, so
Compilation loads no font or rasterizer. For iPhone, the script renders the 512-unit bookmark artwork at 1024px and
removes the alpha channel, because App Store validation rejects a transparent icon. That image replaces iOS Core's
AppIcon.png. Core's AppIcon Contents.json names the image, so Xcode records CFBundleIconName when it compiles
the asset catalog. iPhone receives no separate dark or tinted icon variant.
For Android, the script centers the glyph in a 48dp box inside the 108dp adaptive-icon layer. Launchers show the inner
72dp, and Android asks for a logo of at least 48dp. Android's
safe zone is the
centered circle 66dp across. A 48dp A, V, or X would put its corners outside it, so those glyphs shrink slightly to
fit. That vector replaces Android Core's ic_launcher_foreground.xml.
Core draws it on a white background in light and dark mode and reuses it as the monochrome layer, which Android 13 and
later tint for themed icons. Each Core's standalone fixture keeps a geometric F.
iOS forms and readable content
The first controller in a modal stack supplies UIKit's localized Cancel control when no custom left item exists. Cancel dismisses; the Rails form's Create or Update button saves. A pushed modal controller keeps Back. Core replaces Hotwire's Done item because its checkmark looked like a save action while only dismissing the form. This adds no draft tracking or discard-confirmation bridge. The Rails form also retains its Cancel link to the explicit collection or return URL. That destination may differ from dismissing the native modal stack, so both controls remain; the native item selects no Rails destination.
UIKit and Hotwire retain ownership of physical-screen, navigation-bar, tab-bar, and home-indicator insets. Core
anchors Hotwire's public visitableView below the navigation bar with view.safeAreaLayoutGuide.topAnchor; its
bottom remains at view.bottomAnchor, with automatic scroll insets retained. The WebView scroll view uses
keyboardDismissMode = .onDrag. The local landscape trial kept the focused Rails field below the toolbar, then
dismissed the software keyboard by dragging from that field and exposed the complete Create action. No CSS
safe-area padding, keyboard-height calculation, or JavaScript keyboard bridge participates.
Rails Core uses WebKit's supported
font: -apple-system-body on the native root
so Dynamic Type scales rem text, controls, and spacing together. The observed Simulator root was 17px at default
size and 53px at the largest accessibility size. The rule requires the native body marker and both system-font and
:has() support; otherwise existing root styling
remains. Ordinary browser and Android roots retain their sizing. The CSS cascade preserves configured font family
and line spacing; application-authored fixed pixel sizes do not inherit the scale.
Native buttons wrap within their container and grow vertically while retaining their 3rem minimum and larger
component sizes. Basecoat and React controls share the native sizing policy. Generated Rails submit actions use form.button, because
the Simulator trial's single-line submit inputs could not wrap the full Create Location label. Browser buttons
retain the selected Vega component sizing. The
iOS interaction report (repository-only)
separates temporary probes from passing ordinary-Compilation runs at default and largest accessibility text sizes.
These observations
do not establish physical-device, older-iOS, or general accessibility conformance.
Browser preview with Revyl
Revyl is the primary native preview for iPhone and Android, in a Codespace and on a local computer, by the
2026-09-30 decision (repository-only). Locally, a cloudflared quick
tunnel gives Rails a public HTTPS address. Xcode's Simulator and Android Studio's Emulator are documented
alternatives. The emitted preview guides follow this order. The packaged Skill and the local guide still present the
earlier order. Leave the local guide to the tester guide now in progress.
The local guide covers local Rails, local native builds, and direct Revyl uploads. The evidence index records which Revyl sessions loaded each client through Cloudflare Tunnel and which Android WebView versions they reported. Hotwire's minimum is WebView 120.
Android Studio's local Emulator with a compatible WebView is the Android alternative, and the fallback when a hosted
device reports a WebView older than 120. Rails can also stay in a Codespace behind a private GitHub CLI
port-forward; the Emulator reaches that local port through 10.0.2.2.
The generated Android guide keeps a short optional Emulator section with Debug launch flags. The
private-tunnel observation (repository-only)
proved the connection with an existing APK and Rails app. The later
assisted Codex trial (repository-only) exercised integrated-terminal
bin/dev, a fresh clone opened in newly installed Android Studio, a local build and Emulator interactions, and
Rails-only refresh without rebuilding the APK. Studio's bundled Java 25 worked; forcing its Gradle JVM criteria to
17 failed. The guide retains Studio's default and separately documents JDK 17 for CLI/CI. Core's bytecode target
remains Java 17.
The local Emulator does not require Revyl build or device usage.
Selected output also includes Revyl preview instructions: IOS_PREVIEW.md (repository-only) and
bin/ios preview revyl for iPhone, or ANDROID_PREVIEW.md (repository-only) and
bin/android preview revyl for Android. Each guide offers two ways to give Rails a public HTTPS address: a local
cloudflared quick tunnel with Rails started as RAILS_DEVELOPMENT_HOSTS=.trycloudflare.com bin/dev, or a public
Codespace port. Rails reads that variable at boot, Core also applies it from .env.development.local, and the
leading dot admits every quick-tunnel host. When the preview helper's /up check gets HTTP 403, its error names
that command. The guides also say to run one Revyl
device at a time and to sign in to Revyl in the browser with the CLI's account before opening the Viewer link.
GitHub builds an unsigned Debug Simulator artifact on a Mac runner for iPhone and a standalone debuggable APK on an
Ubuntu runner for Android when native source is pushed, so the Revyl path needs no local Xcode, JDK, or Android SDK.
Rails-only commits reuse an ancestor's artifact when all native build inputs match. Revyl runs the uploaded artifact
against the public Rails address through APP_ROOT_URL. Students keep most Rails iteration in their web preview and
stop Revyl after native checks. GitHub runner allowance and Revyl device usage are separate; this path uses no Revyl
remote-build compute.
Install the latest Revyl CLI with its official installer. The preview helper checks authentication and the app API
before requesting a build; doctor reports the installed CLI version without enforcing exact equality. The owner
accepts ordinary CLI drift: a future command change can be adapted in the generated application. A version bound
needs an observed incompatibility, rather than an untested release alone. Commands and JSON shapes were checked
against v0.1.129 source without a new provider session.
The Codespaces token can push and download artifacts while denying workflow dispatch. The guide therefore retains
an Actions-page fallback for an expired artifact. Debug WebKit loads to the configured Codespaces origin skip its
non-Turbo warning page with the documented tunnel header. The port must still be public, and Release behavior does
not change. The scoped request decoration lives in Core's
AppWebView (repository-only).
Preview configuration holds no credentials and does not require a Revyl GitHub integration. The unsigned artifact
carries no Apple signing identity, and Decision 0008 (repository-only) keeps Revyl
a replaceable adapter over it. Physical iPhone installation is a separate path.
The September 10 smoke (repository-only) preserves provider repairs and the ecosystem comparison. Revyl previews have shown fresh deployed output and sign-in; reliable restart and browser drag and Back remain unshown.
Emitted IOS_PREVIEW.md still teaches pull-down refresh and waiting before a restart.
The September 12 smoke at 651d8d62 showed why those instructions need supplements: browser drags did not refresh,
while this CLI swipe refreshed the default portrait preview:
revyl device swipe down --x 210 --y 310 --duration 1200
Its wait-for-completion advice also did not resolve a restart rejection: Revyl reported its concurrency limit after the first session was Completed and the dashboard showed zero active devices. If that happens, stop and record the error rather than repeatedly retrying or upgrading to continue. The cause remains unresolved. These corrections supplement the emitted iOS guide. The Android Studio follow-up changes no iOS template or runtime and adds no new Revyl observation.
Navigation and tabs
Native does not own which surfaces are top-level. The implemented main navigation (repository-only)
derives browser navigation from admitted public, item-authorized, and environment-gated Scaffold entries plus one
Account entry. Any realized Account contributes account-self:<Account subject_uuid>, the semantic person token,
and /account whether or not iOS is selected. The browser shows Account after resource entries and before Sign out
when current_account is present. Authored profile details compose into that destination under their own read Policy.
Selected iOS and Android clients consume that same navigation, protected entries and Account self included, and
place Account last as the browser does. An omitted client emits no native output.
Both clients open on their first tab. The home_index (repository-only) Entity's
index is therefore the first entry, and the other indexes follow Entity order, so the app opens on the page the Web
root serves. Without a Home choice, or when navigation omits that index, the first Entity's index opens first. With
more than five entries, Home therefore stays a direct tab on both clients rather than moving into More.
The lowering needs two things the derived list does not carry.
An icon per entry. Each index takes its glyph from the optional icon on the Entity behind it, and the
Account self entry takes its fixed person token. Icons are semantic tokens rather than platform names,
in the same way Field types are semantic tokens rather than Ruby classes. A separately versioned platform lowering
maps each token to an SF Symbol on iOS or a Material icon on Android.
That token vocabulary has to be a closed set. An open string would let an author write a name that resolves on
one platform and silently falls back on the other, which is the kind of per-platform divergence this Capability
exists to prevent. Curating the mapping is real platform-lowering work that no icon design avoids. Shared main
navigation turns an omitted Entity icon into the neutral grid token, and the versioned iOS lowering maps that
token to square.grid.2x2 and its selected variant.
A badge, with its count left to ordinary source. The Compiler emits the badge machinery and every entry can carry one; the number comes from application code at render time rather than from the Plan.
Ruby Native draws the same boundary. Its configuration carries only a per-tab boolean, while the count arrives
through a native_badge_tag(count, home:, tab:) view helper. The value that changes per request stayed out of
the static config, and the same reasoning applies here.
Binding a badge to a Predicate was considered. It is expressible today, since a Predicate can compare against
{ "kind": "environment", "name": "current_account" } as Shinar already does. Shinar's unread messages and
Dunbar150's notifications are both natural badges. It was declined for now because a live COUNT on every render
sits badly with the profile's strict_loading and flat-query-count discipline, so a declarative badge would
quietly imply a counter or derived Field as well. Revisit once the
causal seam (repository-only) closes and an inbox is a Plan subject, which would make the
binding express real meaning rather than a query shortcut.
An application with one navigation entry has no tab bar. The client opens that entry directly, which matches Ruby
Native's behavior when its tabs key is omitted. A selected iOS client with no entry is invalid even though an empty
main-navigation result remains valid for the web boundary.
Native Accounts
Authentication stays in Rails, as Joe Masilotti recommends for Hotwire Native apps
(10 tips, tip 3;
authentication deep dive). The
generated Rodauth login, registration, and recovery pages render inside the native web view. The session cookie is
shared with Hotwire's requests, and Rodauth's remember feature restores the session after the app restarts. Native
code holds no credentials and draws no sign-in screen.
Every realized Account emits Rodauth remember with a *_remember_keys table, for browsers and native clients alike.
after_login remembers every login, and an autologin account creation is remembered too. load_memory runs on every
Rodauth middleware request, and before_logout revokes the key. The deadline is a year out, and using the app moves
it a year out again, so people who keep using the app stay signed in, as Joe Masilotti recommends (signed in
forever, tip 5). The Rails authentication
page owns that configuration.
When an Account is realized and iOS or Android is selected, the Compiler also emits:
- Native-only redirects after login, autologin account creation, and logout to
GET /native-session-changed. A login that Rodauth would return to a saved location goes there as well. Browsers keep their existing redirects, and a guest who finishes signup without signing in still goes to sign-in. - That route as a Rodauth middleware route, claimed with the other Account routes. It returns a small page. A Turbo
form submission reaches the native router as a visit proposal. A full-page form post (the generated Rodauth forms
use
data-turbo="false") loads the page, whose script sends the client back through native routing. The route also stores a CSRF token in the session that signing in or out just reset. Otherwise each restarting tab would create its own token, and the shared cookie store would keep whichever session cookie arrived last. A form loaded in another tab, such as Sign out, would then fail Rails' CSRF check. - A
reset_navigation: truepath rule for that route in both bundled configurations. iOS Core rebuilds its navigation container; Android Core calls Hotwire'sresetNavigators()and flushes WebView cookies. Every tab that loaded before the change restarts at its root, so no tab keeps a stale sign-in page or stale signed-in content. This follows the tab-refresh answer in hotwire-native-ios discussion #133. - A Sign out button on the Account page for Hotwire Native requests, because the native layout omits the web header.
A protected tab therefore shows the web sign-in while signed out. Rails redirects a guest to /login both from
before_action :authenticate and from a Policy denial, while a signed-in Account without permission still sees the
403 page. After sign-in or a signup that signs in, every tab reloads signed in; a saved return-to location applies
only to browsers. iOS returns to the first tab, the Home index when one is generated; Android keeps the selected tab.
Gated forms open as modals, so sign-in can start from one. In the iOS Simulator and an Android emulator, after the
session expired on the server, tapping a New link that was still on screen led to sign-in on the tab's own stack with
no modal open. Submitting a form already open in a modal after expiry fails Rails' CSRF check, which answers 422.
With the production error pages, both clients showed "Change rejected" inside the modal, because Turbo 8.0.23
renders a 4xx form response in
place when its
tracked assets match the
current page. Closing the modal returned to the tab: iOS kept the stale signed-in page, whose New link then led to
sign-in, and Android reloaded the tab signed out, so sign-in started from Account. Development's debug page carries no
tracked assets, so there Turbo reloads the form URL instead, which redirects to sign-in. In every case, signing in
reached /native-session-changed, and the reset navigation left no modal on screen. /login has no path rule. Open
question for the owner: whether a CSRF failure without a session should lead to sign-in, which would add a rescue to
register G7 (repository-only).
Deferred: native sign-in screens, biometric unlock, a modal sign-in presentation, preserving the selected iOS tab across a reset, email-verification links inside the app, and push.
Path configuration
ios_v1.json and android_v1.json are derived output. The routes in config/routes.rb already say which URLs are
single-record forms. Hotwire Native's demo configuration maps the bare new and numeric-edit patterns; the optional
query-safe suffix is this Compiler's addition:
| Derived from | Emitted rule |
|---|---|
Each generated new or edit route, gated or not |
context: modal, no pull-to-refresh; optional query |
| A direct navigation entry's route | presentation: replace_root at that root, with or without a query string |
A realized Account's /native-session-changed |
reset_navigation: true, a Core-owned custom property |
| Every other path | default push navigation |
The native_ios and native_android probes in the
support inventory (repository-only) check the emitted
replace_root and default push rules in each client's path configuration. The native_modal_forms probe checks the
modal rule on a form that an Account gates.
Pinned Hotwire Native 1.3.0 enables query-string matching by default and evaluates rules against
path?query
when a query is present. The emitted optional query suffix keeps pagination or filter URLs at a navigation root under
replace_root, rather than adding a Back entry for another state of the same root index. It likewise keeps a
generated form modal when its URL carries a query. Focused Compiler tests prove the emitted expressions; the current
generated-app observation did not tap a query-bearing root or form URL.
Android 1.3.1 independently matches
path?query.
Its rule matcher
uses Kotlin regular expressions. The same optional query suffix therefore preserves direct-root and modal
classification on Android; this is a separate source comparison from the iOS one.
The pinned Navigator.start()
applies path-configuration properties to the initial tab URL. clear_all ignores the proposed controller and
refreshes an existing root, so it can leave that initial empty stack blank. The pinned
replaceRoot
visits the proposed controller and installs it as the root. That behavior fits both first launch and a later link
back to a navigation root.
Both clients recognize context with a modal
value and presentation with a replace_root value, alongside pull_to_refresh_enabled,
query_string_presentation, and historical_location.
The wider vocabularies differ, which is why the first lowering deliberately uses a small subset:
| Key | Both clients | iOS only | Android only |
|---|---|---|---|
context |
default, modal |
— | — |
presentation |
default, pop, replace, refresh, clear_all, replace_root |
none |
push |
modal_style |
— | five sheet styles | — |
uri |
— | — | registered Hotwire fragment destination |
title |
— | — | More supplies a native title |
pull_to_refresh_enabled |
Boolean; default true, forms false |
— | — |
modal_style is an iOS concept with no Android counterpart, so authoring sheet detents would produce output one
platform silently ignores. Check each platform's pinned property accessors and destination registry before extending
its emitted rules; a platform-specific key need not appear in the other client's configuration.
Android's default URI is hotwire://fragment/web; generated new/edit forms retain it with context: modal
and pull_to_refresh_enabled: false. The
pinned demo configuration
uses the same full-screen form pattern. Core also registers an optional bottom sheet and
hotwire://fragment/firstdraft-more.
With more than five entries, /__firstdraft_android_more__ selects the latter with title More. Its overflow routes
keep default push behavior so Back returns to More; only the first four direct roots receive replace_root.
iOS Core recognizes its own /__firstdraft_ios_more__ start location and answers it with the native list, so no path
rule names it. Its overflow routes likewise keep default push behavior.
Ordinary browsers render the same Rails new and edit routes as pages. Native path configuration selects their
modal presentation; it does not convert browser markup into a modal or remove the Rails header.
Served copies
The generated Rails app serves each selected client's bundled file, so the app and the server share one copy:
# config/routes.rb, before every Scaffold route
get "configurations/ios_v1", to: "native/configurations#ios_v1", as: nil
get "configurations/android_v1", to: "native/configurations#android_v1", as: nil
Native::ConfigurationsController renders ios/FoundationApp/Generated/ios_v1.json or
android/app/src/main/assets/json/android_v1.json with render json:, and
spec/requests/native/configurations_spec.rb checks that each answers, byte for byte, without signing in. An
unselected client gets no route or action. The paths follow Hotwire Native's
advice to version one file per platform and to ship both a
bundled and a server copy. Joe Masilotti's Hotwire Native for Rails
Developers serves them from a
ConfigurationsController with ios_v1 and android_v1 actions. The Native namespace keeps that controller apart
from the one an Entity named Configuration generates, and listing the routes first keeps that Entity's
/configurations/:id from claiming them. Nothing in the app links to these routes, so as: nil gives them no
helpers. That also leaves configurations_ios_v1 free for the member helper of an Entity keyed that way.
The actions read no record and need no session, because a client fetches the file at launch, before anyone signs in.
The iOS request can carry the web view's cookies; Android's carries none. Each Scaffold controller declares its own
sign-in and Action Policy checks, so inheriting ApplicationController adds none; its allow_browser check names
only browsers. Docker and Render deploys copy ios/ and android/, because neither .dockerignore nor
render.yaml excludes them.
iOS Core passes the bundled file, then /configurations/ios_v1.json, to the pinned Hotwire Native 1.3.0
loader:
the bundled file loads synchronously, a cached server copy may replace it, and the server request is asynchronous.
Android Core loads the bundled android_v1.json when the application starts and adds
/configurations/android_v1.json as the remote source once its activity resolves the origin, because a Debug launch
can name a local server. The pinned Android 1.3.1
loader
applies a cached server copy, when one exists, instead of the bundled file, then fetches a fresh one. On both
platforms a failed request keeps the current rules. The bundled file therefore covers first launch; after one
successful request, each launch applies the cached server copy, so an unreachable server leaves the last fetched copy
in effect. In the iOS Simulator and an Android emulator, each launch requested its file from the local Rails app and
received the bundled copy.
A rule changed in the repository reaches installed apps from the next deploy, on their next launch; their bundled copy
waits for a new app build. Every installed build follows the deployed file, including rules an older build was not
built for. A Plan change that adds a navigation entry gives its root replace_root, while an older build's tabs were
fixed when it was built. In the pinned sources, a link to that root then replaces the current tab's root
(iOS,
Android).
Hotwire Native's advice for a change older builds cannot follow is to point new builds at a _v2 file and keep
serving the old one. The Compiler emits only the current Plan's _v1 file and keeps no earlier copy. Open question
for the owner: whether generated apps should version their path configuration per build.
Primary source and ownership
The current direction treats the Xcode and Android projects as primary editable source. A generic precompiled First Draft shell would test a different ownership model.
The first owner journey should try an ordinary path to:
- build with local platform tools or a replaceable build provider;
- choose and control signing identities;
- install on owned devices when platform requirements are met;
- distribute through owner-controlled store records;
- configure push credentials and application identifiers; and
- continue with any capable agent or developer.
The in-progress Hotwire Native agent skill is an output and test surface of this work, not an input or authority for the Capability design.
Distinct delivery lanes
Native evidence is reported by job:
| Lane | What it can establish | What it does not establish |
|---|---|---|
| Source generation | Editable projects and integration exist | Either project builds or runs |
| Automated build/test | The pinned project compiles and its tests pass | Interactive behavior or device-only APIs |
| Simulator preview | Rendering, navigation, Bridge, notification UI | Signing, phone install, push, distribution |
| Owner-signed device | Real-device lifecycle and eligible capabilities | TestFlight/App Store or production receipt |
| Beta/store | Owner-controlled installation | Push receipt, background, or tap routing unless exercised |
| Push lifecycle | Registration, receipt, app states, taps, token cleanup | Equivalent behavior on other platform |
An Apple Account can be used for limited Xcode device testing, while TestFlight, App Store distribution, and advanced app services are associated with Apple Developer Program membership. Current details must be checked against Apple's membership comparison and program benefits when implementing or marketing a lane. The recovered native generation mechanics (repository-only) hold a dated July 2026 snapshot of Google's developer-verification and account landscape. Recheck current requirements before designing or making claims about an Android ownership lane.
Push semantics
Push machinery is activated by an authored application-level fact rather than derived, because no Plan subject can imply it. Decision 0016 (repository-only) records why, and requires an Account-bearing Entity before the fact is valid.
Machinery now, rules later
The first lowering emits the machinery and no notification rules. That split is forced by the causal seam (repository-only): nothing in the Plan can yet say that a successful operation should notify a recipient, because Scaffolds model forms without event emission and state-machine effects reach only same-transition Field assignments.
What the first lowering can emit:
- Action Push Native, its device table, and the registration handshake;
- permission prompting after sign-in rather than at launch;
- token storage, replacement, and invalid-token cleanup;
- a test-send surface on the profile page; and
- credential placeholders plus the external prerequisites the owner must satisfy.
photogram-golden demonstrates the shape with app/controllers/account/push_test_notifications_controller.rb,
so this is extraction from working reference source rather than fresh design.
A Foundation that registers devices and can send one test notification proves the whole lifecycle end to end while carrying no rule the format cannot express. When the causal seam closes, notification rules name their own channels and this fact converts to a derived consequence.
Push needs a client to deliver to
ios_push and android_push are separate application-level facts, and each needs its own platform's client. A
Plan enabling android_push while declining native.android registers devices that cannot exist, so semantic
validation should reject that pairing rather than emit a registration handshake with nothing behind it.
The reverse pairing is legitimate. Shipping a client while deferring that platform's push credentials is a reasonable early position, especially for Android, whose FCM lane has no evidence yet.
Browser Push is not a native transport and carries no client dependency. Noticed 3.0.0 has no web-push delivery
method, so web_push would arrive through the separate web-push gem, which the profile has not admitted.
Device storage belongs to the Capability
Action Push Native supplies action_push_native_devices with name, platform, token, and a polymorphic
owner. A Plan that also models a device Entity describes the same table twice, so Shinar's device_installation
Entity was removed once the delivery facts made registration a Capability concern.
A user-visible device list is a different question. "See and revoke your signed-in devices" is product meaning that Action Push Native does not supply, and an application wanting it would model that surface deliberately rather than as a side effect of enabling push. Nothing in the current format prevents it; no fixture asks for it yet.
Lifecycle coordination
The candidate implementation needs to coordinate:
- permission timing as product behavior;
- installation and account binding;
- platform environment and application identity;
- safe logout, account changes, invalid-token cleanup, and replacement;
- after-commit observable delivery work;
- provider errors and retry/discard behavior;
- foreground, background, terminated, and routed-open behavior; and
- protection of tokens, credentials, and sensitive payload data.
Registering a token is not end-to-end push proof.
The application-level notification design (repository-only) owns why one recipient should receive push. This Capability owns native endpoint and client lifecycle. Browser Web Push remains a separate adapter.
Revisit
Reconsider Hotwire Native or this topology when generated apps need substantially different client interfaces, shared web UI becomes a continuation liability, platform policy breaks an ownership lane, or controlled evidence favors another native architecture.