Module map
Twenty-one subsystems under lib/magik/, one reason to change each, mapped to the spec phase they serve.
Status: not implemented. Every subsystem below is a spec-only stub — a front-door file exposing SPEC_PHASE, DSL_SURFACE and STATUS, whose .define raises NotImplementedError — in front of a planned directory and a planned public surface. Re-derive the list with ruby -Ilib -e 'require "magik"; puts Magik::SUBSYSTEMS.keys.join(" ")' and the stub list with grep -rln NotImplementedError lib/magik. Reviewed 2026-08-26.
The shape
One gem. magik ships every subsystem; they are directories, not gems. Extraction stays possible later and is not promised.
lib/magik.rb
lib/magik/<subsystem>.rb # front door — the only file another subsystem may require
lib/magik/<subsystem>/… # one responsibility per file
lib/magik/<subsystem>/errors.rb # this subsystem's MAGIK_* codes
lib/magik/<subsystem>/backends/… # only where the subsystem owns a swap point
If two things in a subsystem change for different reasons, they are two objects inside it — not two subsystems. Adding a subsystem is an architecture change: it needs a tier, a public surface, and a row here.
Tiers
A subsystem may require strictly lower tiers only. Never sideways, never upward. The full rule and its enforcement: 02-boundaries.md.
tier 0 core
tier 1 schema, router, i18n, policy
tier 2 model, render, realtime, jobs
tier 3 action, ledger, api, auth, notify, pwa
tier 4 billing, admin, domains
tier 5 check, testing
tier 6 cli
There is no exceptions column, on purpose. The first sideways exception is a signal that a tier is wrong; argue the tier instead.
The executable copy of this table is ../../scripts/lib/tiers.rb, and ../../scripts/checks/boundaries.rb re-parses this page and 02-boundaries.md against it on every run. When prose and code diverge, the code is right and the prose is the bug.
Why policy is tier 1
The tier is load-bearing rather than incidental, and the spec argues it explicitly (../idea/00-build-spec.md, decision 13 and Phase 2):
render and realtime are tier 2 and both must evaluate policies |
imports go strictly down, so an evaluator sitting beside auth at tier 3 would be unreachable from the two surfaces that need it most |
policy therefore takes the actor as an opaque value |
exactly as router at tier 1 takes a path without knowing what a screen is. It decides; it does not identify |
auth stays at tier 3 and supplies the actor |
it never decides. That split is what lets policy land in phase 2 and auth in phase 7 with nothing in the policy layer changing at the handover |
Authorization is deliberately not a swap point (../idea/04-swap-points.md): a second authorization backend is a second authorization system.
Every subsystem
| Subsystem | Tier | Phase | Responsibility (one line) | Intended public surface | Must never | Status |
|---|---|---|---|---|---|---|
core |
0 | 1 | errors, config, the declaration registry, the boot pipeline, the value types | Magik::Error, Magik.config, Magik::Registry, Magik::Money, Magik::Uuid7, Magik::Timestamp, the guardrail runner and rule interface |
require any other subsystem; know what a screen or a job is | not implemented |
schema |
1 | 1 | the migrate DSL over Sequel migrations, column type mapping, drift detection |
migrate, Magik::Schema.apply, Magik::Schema.drift |
contain business logic; know about models' associations | not implemented |
router |
1 | 2 | the path grammar and the route table — name → path, both directions | Magik::Router.path_for, Magik::Router.resolve, Magik::Router.table |
know concretely about action or screen; render anything |
not implemented |
i18n |
1 | 8 | catalogs, locale negotiation, t(), loud misses |
t, locales, Magik::I18n.catalog |
read a request; format money or timestamps itself | not implemented |
policy |
1 | 2 | the policy DSL and the one authorization evaluator every surface calls — verbs, default :deny, pure predicates, the roles / staff_roles sets |
policy, roles, staff_roles, Magik::Policy.allows?, Magik::Policy::Verb |
identify the actor — it receives one as an opaque value; issue a query or any other I/O inside a predicate; be swappable (above) | not implemented |
model |
2 | 1 | the model DSL over Sequel — fields, associations, validations, scopes, tenancy, audit annotations, attachment (phase 4b) |
model, Magik::Model::Definition, audited, immutable_after, attachment |
render; authorize; reach another domain's models | not implemented |
render |
2 | 2 | the component/screen/layout DSL and the HTML + hx-* compiler, the component kit, the theme system, the override registry |
component, screen, layout, Magik::Kit::*, Magik::Render.compile |
hold state across requests; import realtime (it needs a channel name, which lives in core); write its own authorization check — a screen names a verb and policy (1) decides |
not implemented |
realtime |
2 | 3 | channel/broadcast/presence and the pub-sub transport |
channel, broadcast, presence, backends/postgres.rb, backends/redis.rb |
authorize on its own — a channel evaluates the same policy verb as the request path; open a socket nobody declared |
not implemented |
jobs |
2 | 4 | the job DSL, the durable queue, retries, the scheduler, the worker loop |
job, Magik::Jobs.enqueue, Magik::Jobs::Worker, backends/postgres.rb |
serve HTTP; assume exactly-once delivery; run without a declared policy: verb (policy: :system is the usual opt-out) |
not implemented |
action |
3 | 2 | the action DSL, params coercion, the mutation lifecycle, redraw/toast/redirect, idempotent_by |
action, Magik::Action.perform, redraw, toast |
hold instance state; assemble HTML by hand; run slow work inline | not implemented |
ledger |
3 | 5 | the ledger DSL, double-entry posting, balance analysis, append-only enforcement |
ledger, Magik::Ledger.post, Magik::Ledger.balance |
accept a float; update or delete a posted entry | not implemented |
api |
3 | 6 | the api DSL, REST resources, pagination/filter/sort, webhook in both directions, API auth and rate limiting |
api, webhook, Magik::Api::Resource |
render screens; bypass model validations | not implemented |
auth |
3 | 7 | the auth DSL over Rodauth — accounts, sessions, OAuth, 2FA, and the actor a request resolves to |
auth, Magik::Auth.actor, Magik::Auth::Account |
decide authorization for a resource — it identifies | not implemented |
notify |
3 | 8 | the notification DSL and delivery across email, in-app and push |
notification, notify, Magik::Notify::Channel |
deliver inline — delivery goes through jobs |
not implemented |
pwa |
3 | 8 | the pwa DSL, the manifest, icons, install metadata |
pwa, Magik::Pwa.manifest |
emit a service worker that caches application data (../idea/05-limits.md) |
not implemented |
billing |
4 | 7 | the billing DSL — plans, trials, subscriptions, metered usage, provider webhooks |
billing, Magik::Billing.subscribe, backends/stripe.rb |
own money arithmetic — that is core's Money and ledger |
not implemented |
admin |
4 | 7 | the admin_panel DSL and the generated CRUD admin |
admin_panel, Magik::Admin.mount |
be a second app with its own auth or rendering path | not implemented |
domains |
4 | item 12 | the domain DSL, the dependency graph, and boot-time boundary enforcement |
domain, Magik::Domains.graph, Magik::Domains.enforce! |
be optional — an app with no domains has one implicit domain | not implemented |
check |
5 | item 12 | the static rule set behind magik check, --scale heuristics, finding formatting, --json |
Magik::Check.run, Magik::Check::Finding |
duplicate a boot guardrail — it runs the same rule objects without a server | not implemented |
testing |
5 | 9 | the test DSL over Minitest, factories, helpers, the parallel runner, transactional isolation |
test, Magik::TestCase, perform_action, render_screen, assert_enqueued |
be required from lib/ production code paths |
not implemented |
cli |
6 | 1 | exe/magik — the command surface, generators, the dev server, magik worker, magik console, magik describe |
Magik::Cli.start, the command classes |
contain framework logic — it composes subsystems and prints; hand-maintain a second copy of the grammar (magik describe serializes the option tables, below) |
not implemented |
The Phase column names a spec phase (../idea/06-phases.md). item 12 is the spec's Domain module system architecture decision rather than a numbered phase; it is built at build-order step 13.
The option tables and magik describe
magik describe is a phase-1 CLI command and the grammar, as data — every construct, declaration and option with its type, default, allowed value set and the MAGIK_* codes it can raise. It is built at build-order step 2, with the option tables it serializes.
| Rule | Detail |
|---|---|
| Where the tables live | with the construct — one Option value object per option, in the subsystem that owns the construct. There is no central options file |
| Who reads them | the coercer at boot, the guardrail rules, the docs anchors, and magik describe. One artifact serialized four ways |
| Never hand-maintained | two tables drift; one cannot. A describe output written by hand is the defect this design exists to prevent |
Not magik registry |
describe is what the grammar allows and needs no app; registry is what this app declared and needs a booted one (06-observability.md). Neither may grow into the other |
The design and the rules that follow from it: ../idea/11-dsl-as-tool-surface.md §2, indexed in 00-conventions.md.
Media has no subsystem, on purpose
Phase 4b spans four existing ones rather than adding a twenty-second (../idea/06-phases.md):
| Piece of media | Lives in |
|---|---|
the :file field type and attachment on a model, plus the storage seam |
model (2) |
the upload component and srcset / responsive rendering |
render (2) |
derivative generation and the transcoding seam (use :media, :mux, wrapped never built) |
jobs (2) |
| signed expiring URLs, issued only after the record's verb passes | policy (1) decides; the surface issuing the URL calls it |
Adding a subsystem is an architecture change, and media does not need one — it needs a declaration on model and job work behind it.
Where the guardrails live
A deliberate split, because the naive design puts core above check and inverts the whole tier table:
| Piece | Lives in | Why |
|---|---|---|
| The rule interface and the boot runner | core |
boot must run rules without knowing which subsystems exist |
| Each boot rule | the subsystem that owns the concept — the balance rule in ledger, the PAN-field rule in model, the boundary rule in domains |
one reason to change, and the rule ships with the construct it guards |
The static rule set, --scale, finding formatting, --json |
check |
it needs to see everything, so it sits near the top |
This is why core never requires check, and why "a phase is not done without its guardrails" (../idea/06-phases.md) is a statement about the subsystem's own directory.
Where the swap points live
Each seam is owned by exactly one subsystem, and every backend lives under that subsystem's backends/:
| Seam | Owner | Directory |
|---|---|---|
| Database | core (connection) + model/schema (dialect) |
lib/magik/core/backends/ |
| Cache | core |
lib/magik/core/backends/ |
| Jobs | jobs |
lib/magik/jobs/backends/ |
| Realtime | realtime |
lib/magik/realtime/backends/ |
| Search | model |
lib/magik/model/backends/ |
| Storage | model (uploads via Shrine) |
lib/magik/model/backends/ |
| Billing provider | billing |
lib/magik/billing/backends/ |
| Component kit | render |
lib/magik/render/kit/ + the override registry (../idea/08-component-overrides.md) |
Rules for adding one: ../idea/04-swap-points.md.