The dev loop
Hot code reloading, the dev server and the console, designed as a subsystem — because the inner loop is where a developer or an agent spends every minute that is not spent waiting on CI.
Status: planned. There is no dev server, no reloader and no file watcher. Reviewed 2026-08-26.
What the loop is
edit a declaration → save → registry rebuilt → next request serves the new code
No restart, no build step, no bundler, no compile. The target is sub-second, and it is a target with a command that will measure it, not a benchmark:
magik server --report=reload # prints ms per reload and what triggered it
What reloads, and what cannot
| Reloads | Why it can |
|---|---|
model declarations |
a declaration is a value in a registry, not an open class scattered across files |
screen, component declarations |
same, plus the compiled HTML is derived, so it is regenerated |
action declarations |
same |
job declarations |
the definition reloads; a job in flight finishes on the code it started with |
channel declarations |
the definition reloads; existing subscriptions are re-attached to the new definition |
ledger, flow, api, webhook, notification, admin_panel declarations |
all registry-backed |
| locale catalogs | re-read from disk |
plain Ruby under app/ |
reloaded with the declarations that reference it |
| Requires a restart | Why not |
|---|---|
App.define — the composition root |
it selects the seams and opens the connections; re-running it mid-process is the MAGIK_BOOT_REDEFINED case (../idea/03-guardrails.md) |
initializers and config/ |
they configure long-lived objects — pools, backends, the logger |
Gemfile / Gemfile.lock |
the loaded gem set is a property of the process |
| migrations | a schema change is applied with magik migrate, and the reloader re-reads the schema afterwards |
the framework's own lib/magik/ |
when developing Magik itself, not an app |
Refused loudly, never stale. When a change lands in a file that cannot be hot-reloaded, the dev server does not quietly serve the old code: it prints what changed, why it needs a restart, and the command — and, with --restart-on-boot-change, restarts itself. The rule from the spec applies here too: no silent failure.
config/app.rb changed — the composition root cannot be hot-reloaded.
reason: it selects backends and opens connections
fix: restart the server (or run: magik server --restart-on-boot-change)
Serving previous code until then.
The mechanism
Prior art
| Prior art | How it works | What it pays |
|---|---|---|
Rails ActiveSupport::Reloader |
a file watcher marks the app dirty; to_prepare hooks run; autoloaded constants are unloaded and re-resolved on next reference |
unloading constants is the hard part — anything holding a reference to the old class keeps it alive, and the two versions coexist |
Zeitwerk::Loader#reload |
removes the constants it defined and re-resolves them from the file tree | the same hazard, cleanly implemented: it can only manage what it defined, and it cannot know who kept a reference |
Both are fighting the same fact: in Ruby, a constant is a reference other objects can capture, and unloading it does not reach those captures.
What Magik does instead
Rebuild the registry; do not chase constants.
Every declaration is registered in a known registry (01-module-map.md) rather than scattered through open classes and callback lists. So a reload is:
1. watch file changed under app/ or domains/
2. graph resolve the changed file to the declarations it defines, plus dependents
3. rebuild re-evaluate those declaration files into a NEW registry, off to the side
4. guardrail run the boot guardrails against the new registry
5. swap atomically replace the live registry — or, on failure, keep the old one and show the error
6. freeze the new registry is frozen, exactly as at boot
| Property | Consequence |
|---|---|
| Deterministic | the new registry is built from source, not patched. There is no half-updated state |
| Atomic | in-flight requests finish against the registry they started with |
| Guardrailed | a reload that would violate a boot rule fails the reload, not the next request an hour later — the same codes as boot |
| Idempotent subscriptions | channels, jobs and webhooks are entries in the new registry, so a reload cannot double-register a subscriber. That failure mode is structurally absent, not defended against |
The hazards Ruby reloading always has
Named, because a page claiming reloading is solved would be the dishonest version:
| Hazard | Magik's answer |
|---|---|
| Stale constant references | app code refers to declarations by name through the registry (Order, resolved per request), not by captured class object. Code that captures a Ruby constant directly is the case that can go stale, and magik check reports it |
| Values memoized at boot | memoization inside a declaration body is re-evaluated on rebuild. Memoization inside an initializer is not — which is part of why initializers are restart-only |
| Subscribers registered twice | impossible via the registry path, since the registry is replaced rather than appended to |
| Long-lived objects holding old definitions | jobs in flight and open channel connections; both are explicitly documented as finishing on the code they started with |
Spec item 9 is what makes this tractable: app servers hold no in-process state across requests. There is no session cache, no per-connection view state and no memoized request-scoped graph to reconcile — so swapping the registry between two requests is safe by construction, and the stateless guardrail is the thing that keeps it that way (../idea/03-guardrails.md).
The dev server
magik server # Puma, dev mode, watching
magik server --port 3000
magik server --report=reload # reload timings
magik server --restart-on-boot-change
| Property | Detail |
|---|---|
| Server | Puma in dev and in production — one server, so dev behaviour predicts production behaviour |
| Watching | the OS file-watch API where available, polling as a fallback; the watched set is app/, domains/, config/locales/ |
| Debounce | rapid saves coalesce into one rebuild |
| Target | sub-second from save to served, measured by --report=reload |
When reloaded code raises, the developer gets the same object the CLI would print — code, cause, app-code location, fix: line — as an error page, with framework frames collapsed (06-observability.md). Never a raw backtrace, and never a different string from the one the terminal shows.
The browser half
There is no JavaScript build step because there is no JavaScript to build (../idea/05-limits.md). The browser side of a reload:
| Change | Browser behaviour |
|---|---|
| a screen, component or action body | the next htmx request returns the new HTML — no refresh needed for the part being worked on |
| the currently open page's own structure | the dev server pushes a reload signal on the dev channel and the page refreshes once |
| CSS tokens / theme | the stylesheet is re-fetched; no page reload |
| a model or job with no visual effect | nothing visible; the reload line in the terminal is the feedback |
The dev reload channel is a dev-only concern and is not present in a production build.
magik console
magik console # boots the app, guardrails included, into IRB
> reload! # rebuild the registry in place
> Order.recent.sql # inspect, not guess
> magik_routes # the same table as `magik routes`
The console shares the reloader with the dev server, and the dependency graph it uses is the same one magik test --changed and --watch use (04-testing-strategy.md). One implementation, three consumers:
| Consumer | Question it asks the graph |
|---|---|
| the dev-server reloader | what declarations does this changed file define, and what depends on them? |
magik test --changed |
which tests can this diff affect? |
magik test --watch |
which tests should re-run right now? |
A second implementation of "what changed and what depends on it" would drift, and the drift would be invisible — a reload that missed a dependent and a test run that skipped one look identical to working correctly.
Generators are part of the loop
magik generate model Order
magik generate screen Orders
magik generate action refund_order
Generated files are live immediately — the watcher picks them up, the registry rebuilds, and the route exists on the next request. No restart, no registration step, no route file to edit. Each generator also emits a failing test, so the loop's next move is obvious (05-adding-a-feature.md).
A generated surface arrives declared, not blank. A screen or an action reaches a model, so it names a policy: verb and a layout:; a generator that emitted either without them would emit a file whose only effect is to fail the next reload with MAGIK_POLICY_UNDECLARED or MAGIK_LAYOUT_MISSING. The generated authorization test — one per verb, including a cross-tenant denial — comes in the same pass (04-testing-strategy.md). This is what makes the reload loop usable rather than a boot-failure loop: the guardrails run on every rebuild, so anything the generator leaves undeclared is felt on the next save rather than at deploy.
Configuration in the loop
Config and secrets interact with reloading in exactly one way worth stating: they do not reload. Credentials, .env files and config/ are read once, at boot, because the objects they configure are long-lived (07-configuration-and-secrets.md). Changing one prints the restart notice above rather than being partially applied.