aontu

rb-solar: a Rails application, generated

Ruby on Rails 8, SQLite, Hotwire: the Solar System API (Planet, Moon) and a human UI over the same data

Held to voxgig-sdk/voxgig-solardemo-sdk, which supplies the OpenAPI description, the validation script and the Ruby SDK

Status nine checks, including the reference's own twenty tests

Rendered from test/system/rb-solar/README.md in the engine repository. The model, the generators, the committed application and the check.sh that renders and validates all of it are in test/system/rb-solar/.

$ entity moon (27) planet (21) error conflict (5) invalid (5) not_found (5) seed moon (21) planet (8) sequence 0 (21) 1 (27) service api "/api" name "solar" port 8901 root "planets#index" title "Solar System"
The model's key tree

Entities and relationships

The Rails app manages planets and their moons. It serves a JSON API and HTML pages over the same SQLite database. The seed data contains eight planets and twenty-one moons from the Solar System reference API.

EntityFieldsRelationship
PlanetString id, name, kind, diameter, terraform_state, forbid_state, forbid_reasonHas zero or more moons
MoonString id, name, kind, diameter, planet_idBelongs to one planet

Both entities use caller-supplied string IDs. Active Record validates the required fields. A moon requires a planet, and deleting a planet destroys its moons through the Active Record association.

moonsPLANETfloat diameterstring forbid_reasonstring forbid_statestring id PKstring kindstring namestring terraform_stateMOONfloat diameterstring id PKstring kindstring namestring planet_id FK
Planet and Moon: database fields, primary keys, and foreign key
Mermaid source
%% Generated by aontu from model.aon. Do not edit.

erDiagram
  PLANET ||--o{ MOON : "moons"
  PLANET {
    float diameter
    string forbid_reason
    string forbid_state
    string id PK
    string kind
    string name
    string terraform_state
  }
  MOON {
    float diameter
    string id PK
    string kind
    string name
    string planet_id FK
  }

The ERD uses database column names. JSON responses use terraformState, forbidState, and forbidReason for the corresponding planet fields; planet_id keeps the same name in the database and API.

Learn how aontu generates this ERD, from the parent relationship and field flags to the Mermaid output.

The Rails application

The browser pages list planets, show a planet, list its moons, and show a moon. These pages are read-only. The API provides create, read, update, and delete operations for both entities, plus the planet actions forbid and terraform.

InterfaceRoutesResponse
Browser/, /planets, /planets/:planet_idPlanet list or details
Browser/planets/:planet_id/moons, /planets/:planet_id/moons/:moon_idA planet’s moon list or moon details
Planet API/api/planet, /api/planet/:planet_idJSON records
Moon API/api/planet/:planet_id/moon, /api/planet/:planet_id/moon/:moon_idJSON records scoped to a planet
Planet actions/api/planet/:planet_id/forbid, /api/planet/:planet_id/terraformJSON action result

The API returns errors as {error, message} with status 400 for invalid input, 404 for a missing record, and 409 for a duplicate ID. Browser controllers return 404 when the requested record is absent.

Application architecture

Routes select either a browser controller or an API controller. Both use the Planet and Moon Active Record models. Browser controllers pass records to ERB views; API controllers serialise records as JSON.

HTML requestAPI requestrecordsread or writerender HTMLserialiseBrowserAPI client or Ruby SDKRails routerconfig/routes.rbPlanetsControllerMoonsControllerApi::PlanetsControllerApi::MoonsControllerApi::BaseControllerERB pagesapplication layoutJSON recordsor error responsesPlanet and MoonActive RecordSQLiteplanets and moons
Rails application architecture: browser and API paths share the Active Record models
Mermaid source
flowchart TB
  browser["Browser"]
  client["API client or Ruby SDK"]
  routes["Rails router<br/>config/routes.rb"]
  ui["PlanetsController<br/>MoonsController"]
  api["Api::PlanetsController<br/>Api::MoonsController<br/>Api::BaseController"]
  views["ERB pages<br/>application layout"]
  json["JSON records<br/>or error responses"]
  models["Planet and Moon<br/>Active Record"]
  db[("SQLite<br/>planets and moons")]
  browser -->|"HTML request"| routes
  client -->|"API request"| routes
  routes --> ui
  routes --> api
  ui -->|"records"| models
  api -->|"read or write"| models
  ui -->|"render HTML"| views
  api -->|"serialise"| json
  models --> db

The API controllers inherit error handling from Api::BaseController. Browser controllers inherit from ApplicationController and use the shared application layout. The app has no separate service or repository layer: controllers call Active Record directly.

Application layers

The layers describe the running Rails app. aontu is used to generate source files before the app runs; it is not part of request handling.

HTTP entryPuma and Rack: handwritten setupRails routes: generatedRequest and presentation layerUI and API controllers: generatedERB pages: generatedcontroller base and layout: handwrittenDomain and persistence layerPlanet and Moon: generatedvalidation and associationsApplicationRecord: handwrittenStorageSQLite databasemigrations and seeds: generateddatabase configuration: handwritten
Rails layers: HTTP routing, controllers and views, models, and SQLite
Mermaid source
flowchart TB
  http["HTTP entry<br/>Puma and Rack: handwritten setup<br/>Rails routes: generated"]
  presentation["Request and presentation layer<br/>UI and API controllers: generated<br/>ERB pages: generated<br/>controller base and layout: handwritten"]
  domain["Domain and persistence layer<br/>Planet and Moon: generated<br/>validation and associations<br/>ApplicationRecord: handwritten"]
  storage[("Storage<br/>SQLite database<br/>migrations and seeds: generated<br/>database configuration: handwritten")]
  http --> presentation
  presentation --> domain
  domain --> storage

The generated controllers and models contain this example’s application behaviour. Rails supplies routing, view rendering, validation, and database access. Handwritten configuration sets up the runtime and shared UI layout.

Folder structure and file ownership

Paths below are relative to the example’s app/ directory. [G] marks files generated by aontu; [H] marks files maintained by hand, including Rails scaffold files. Directories can contain both kinds.

app/                                      Rails application root
├── Gemfile                               [H] dependencies
├── Gemfile.lock                          [H] the resolved gem set, pinned
├── Rakefile                              [H] Rails tasks
├── config.ru                             [H] Rack entry point
├── bin/                                  [H] Rails and development commands
├── config/
│   ├── routes.rb                         [G] browser and API routes
│   ├── application.rb                    [H] application configuration
│   ├── database.yml                      [H] SQLite configuration
│   ├── puma.rb                           [H] web server configuration
│   ├── boot.rb, environment.rb           [H] Rails startup
│   ├── environments/                     [H] per-environment settings
│   ├── initializers/                     [H] assets, logging, inflections
│   └── locales/                          [H] translations
├── app/
│   ├── controllers/
│   │   ├── application_controller.rb     [H] browser controller base
│   │   ├── planets_controller.rb         [G] planet HTML pages
│   │   ├── moons_controller.rb           [G] moon HTML pages
│   │   └── api/
│   │       ├── base_controller.rb        [G] API error responses
│   │       ├── planets_controller.rb     [G] planet CRUD and actions
│   │       └── moons_controller.rb       [G] moon CRUD
│   ├── models/
│   │   ├── application_record.rb         [H] Active Record base
│   │   ├── planet.rb                     [G] fields and moon association
│   │   └── moon.rb                       [G] fields and planet association
│   ├── helpers/application_helper.rb     [H] shared view helper
│   └── views/
│       ├── layouts/application.html.erb  [H] shared page layout
│       ├── planets/index.html.erb        [G] planet list
│       ├── planets/show.html.erb         [G] planet details
│       ├── moons/index.html.erb          [G] moon list
│       └── moons/show.html.erb            [G] moon details
└── db/
    ├── migrate/*_create_planets.rb        [G] planet table
    ├── migrate/*_create_moons.rb          [G] moon table
    └── seeds.rb                          [G] reference planet and moon data

Generated files carry a banner naming aontu and model.aon. The byte gate compares them with the generator output and reports manual changes as drift. It does not manage the handwritten files. Database contents, logs, and temporary runtime files are not generated application source and are omitted from this tree.

The surrounding example directory contains the inputs and checks:

rb-solar/
├── app/          Rails app: mixed ownership, detailed above
├── model.aon     Handwritten service, entity, field, and action definitions
├── gen/          Nine handwritten generators; the diagram's in gen/doc/
├── ref/          Pinned API reference, seed data, and validation clients
├── doc/          Generated ERD and model views; authored architecture diagrams
└── check.sh      Generation checks and live application checks

Running it

cd app && bundle install     # once
cd .. && ./check.sh

check.sh skips the server checks with a note when Ruby or the bundle is absent, so the generation checks can still run. It honours $AONTU, so the Go port runs the same check, and RB_SOLAR_PORT when 8901 is taken.

To drive the app by hand:

cd app
bin/rails db:reset
bin/rails server -p 8901

Then http://localhost:8901/ for the pages and http://localhost:8901/api/planet for the API.

What it is held to

ref/ carries the reference verbatim at one commit and nothing there is edited: see test/system/rb-solar/ref/README.md, which also records the one place the OpenAPI description and the executable validation disagree, and why the executable one wins.

check.sh runs nine checks, including two against the reference API:

  • ref/validate.ts, the reference repository’s own script, unmodified. It sends HTTP requests to the Rails app. All twenty of its tests pass, cascade delete and error envelope included.
  • the reference’s Ruby SDK, driving the app through its real client, in test/system/rb-solar/ref/sdk_live.rb: thirteen assertions against the running server. The check exits 1 if no server is available.

The model

test/system/rb-solar/model.aon is the whole input. It states the service, two entities with their fields and URL shapes, the two planet actions, the error envelope, and it includes the reference’s seed data as data rather than copying it, so the rows this app serves and the rows the reference serves cannot drift apart.

Seven things in it are worth reading for the reasons behind them:

  • The named collections are maps, keyed by their own names. The entities, an entity’s fields, its actions and the error envelope are all maps: $.entity.planet.field.diameter addresses a field, parent: "planet" on the moon names a key that exists, and a new one anywhere is an insertion rather than a position. A list would have given each of them a number nobody chose, that a reader has to count to and that moves when something is inserted in front of it.
  • Declare any required order in the model. A map is walked in sorted-key order, so the order that is on the page is stated rather than smuggled in: sequence lists the two entities in the order the migrations create them and the seeds insert them, because a moon keys into a planet, and an action’s rule stays a list for the reason set out below. Five of the nine generators write one file per entity and never see an order at all.
  • Every field states its JSON name. The wire says terraformState where the column says terraform_state, and planet_id either way. What a field is called in a target is a fact about the model, not a rule in a template; a generator that derived one from the other would be guessing, and would be wrong three times out of seven here.
  • Every field answers pk and fk, including with false. Rule tables need an explicit boolean to select fields by these flags.
  • An index page’s key column is asked for, not assumed. A table’s header row and its body row are two dispatches over the same fields, and the body writes the key’s cell itself because that one is a link. While field was a list with id written first, one loop over the fields put the key column first in both rows by luck; a map sorts, and every planet’s diameter came out under a header reading id. Both rows now name the key first, and check.sh reads the live page and asks what is under name.
  • An action’s rules are listed lowest priority first. The generated form is a sequence of assignments and the last one wins, so this reproduces the reference’s if/elsif: {start: true, stop: true} is terraforming, not idle: a case the reference’s own validation never exercises.
  • Everything a generated line needs is on the node its rule matched. An entity’s indexes and seed rows are stated under the entity, each row carrying its own table or class, because a nested dispatch cannot see the node the enclosing one matched.

Follow the model guide to inspect these values and see how generators select them.

The generators

Nine of them, in test/system/rb-solar/gen/: the eight that write app/ directly in it, so one aontu render gen app writes them, and the diagram generator in test/system/rb-solar/gen/doc/, since it writes doc/. Eight are files in the language they generate: a marked line carries the aontu and the target’s own tools read the rest, so ruby -c parses the seven Ruby ones and a Mermaid renderer draws the diagram:

generatorwrites
routes.rbconfig/routes.rb, the UI and API routes
migrate.rbone migration per entity, columns in model order
seeds.rbthe reference’s eight planets and twenty-one moons
model.rbthe Active Record classes, associations, cascade, validations
api_base.rbthe {error, message} envelope, one method per error
api_controller.rbthe five verbs, the actions, the serialiser, the parent scope
ui_controller.rbthe page controllers
erd.mmdthe ER diagram: a Mermaid file whose marker is %%-
views.aonthe four ERB pages, in canonical aontu

views.aon is the exception, for a reason in ERB rather than here: ERB’s only comment is <%# … %>, a delimited form closing %>, and the template surface’s block marker is fixed to the C family (/*-*/). No marker an ERB file can carry is one ERB itself ignores, so a generator written as an .html.erb file could not stay valid in its own language, which is the whole promise.

erd.mmd shows the other side of that: Mermaid is not in the marker table, and one --marker '%%-' is all it costs.

All nine are in the form aontu fmt writes, and check.sh says so. A generator is two documents on one page (the target’s, in its own lines, and aontu’s, in the marker lines) and only the first one used to have a shape you could read. The marker now stands at the left margin with the aontu indented after it, so the tree is visible as a tree:

#-   emit([$.service.root], {
#-     match: string
#-     replace: ROOT: _
#-     body: [
  root "ROOT"
#-     ]
#-   })

Nothing in this moves a line of the generated app: the byte gate compares byte for byte, and the first check in check.sh is that one.

The Rails template guide follows a service value into a route and required fields into Active Record validations.

How the app is generated

One model, nine generators, one tree. The byte gate compares what the generators would write against what is committed, so the arrow back is a gate rather than a suggestion.

seed rows included as data,never copiedaontu answers the tree,a generator runtime writes the bytesa hand edit to a generatedfile is reported as driftthe referencevoxgig-solardemo-sdkmodel.aonservice · 2 entities · 2 actionserror envelope · seed dataseven Ruby filesroutes · migrate · seeds · modelapi_base · api_controller · ui_controllergen/: nine generatorserd.mmdmarker %%-gen/: nine generatorsviews.aonthe four ERB pagesgen/: nine generatorsgen/: nine generatorsapp/the committed Rails treedoc/ERD, trees, latticethe byte gatenine checks in check.sh
Mermaid source
flowchart LR
  ref[("the reference<br/>voxgig-solardemo-sdk")]
  model["model.aon<br/>service · 2 entities · 2 actions<br/>error envelope · seed data"]
  ref -->|"seed rows included as data,<br/>never copied"| model

  subgraph gen["gen/: nine generators"]
    rb["seven Ruby files<br/>routes · migrate · seeds · model<br/>api_base · api_controller · ui_controller"]
    mmd["erd.mmd<br/>marker %%-"]
    views["views.aon<br/>the four ERB pages"]
  end
  model --> gen

  app[("app/<br/>the committed Rails tree")]
  doc[("doc/<br/>ERD, trees, lattice")]
  gen -->|"aontu answers the tree,<br/>a generator runtime writes the bytes"| app
  gen --> doc

  check{{"the byte gate<br/>nine checks in check.sh"}}
  app --> check
  check -->|"a hand edit to a generated<br/>file is reported as drift"| app

Changing the application

Choose where to make a change according to who owns the file. Generated files come from the model and generators. Handwritten files are maintained as ordinary Rails code.

Follow change and check the generated app to add a field, regenerate the outputs, and check the resulting diff.

Where to edit

you are changingeditwhat holds it
a fact about the system: a field, an entity, a route shape, an action’s rules, a seed rowmodel.aonthe model is the only statement of it; every target follows
how a fact becomes code: the shape of a controller, the columns a migration writesthe generator in gen/it stays a valid file in its own language, so ruby -c still parses it
anything the model does not decide: a Gemfile, an initialiser, a background job, a service object, a bespoke querythe hand-written setnothing; it is ordinary Ruby, reviewed like ordinary Ruby

The boundary is not a convention anyone has to remember. Every generated file opens with

# Generated by aontu from model.aon. Do not edit.

and the byte gate compares the whole tree against what the generators would write. A hand edit to a generated file is drift, and the check names the file.

Extending generated code

The interesting case is not the one the rules cover. It is the day someone needs a generated file to do something the model has no way to say. Choose how to extend the generated application:

  1. Lift it into the model. The change is a fact about the system, so state it once and let every target that cares consume it. This is right when the same fact would otherwise be repeated by hand in the API controller, the UI controller and the ERD.
  2. Teach the generator. The change is about how a fact becomes code, not about the system. The model does not move; one rule does.
  3. Move the file out. Delete its rule, drop the banner, and it becomes an ordinary hand-written file. This is a real option and sometimes the right one: a controller that has grown genuinely bespoke logic is no longer a consequence of the model, and pretending otherwise makes the generator worse for everything else.

What is not an answer is editing the generated file and leaving it. That is the state the check exists to make visible in the next check.

Checking agent changes

An agent can edit a generated file without updating its model or generator. Run the checks to detect that mismatch:

  • Changed generated files are identified. The byte gate answers, in one command and over the whole tree, whether an edit landed on a file the model owns. No reviewer has to hold the generated set in their head.
  • The fix is mechanical. Drift on a generated file means one of the three answers above, and the diff shows which: a hand edit that the generator would also have written is a model change waiting to be named; one it would not is a bespoke change that has to move out.
  • The API behaviour is tested. The nine checks end with other people’s code: the reference’s twenty validation tests and its own SDK, driving the running app. A change can satisfy every structural check and still be wrong, and those legs are what say so.

The generated half and the hand-written half are reviewed differently on purpose. A diff to app/ under a generated banner should be read as a diff to model.aon or to gen/, because that is what caused it, and the check will not let it be anything else.

Diagram sources

The ERD is generated from the same entity definitions as the Rails models. The model tree, planet tree, and value lattice are produced by aontu view and checked against committed text and SVG files. The application architecture and layer diagram describe the committed Rails source.

Learn to use aontu with this example