Boundaries
Two boundary systems: the tier rules between Magik's own subsystems, and the domain rules an app declares. Both are meant to fail the build or the boot, never a review.
Status: rule one is enforced, rule two is planned. The tier checker exists and runs — ruby scripts/checks/boundaries.rb — over an executable copy of the table in ../../scripts/lib/tiers.rb; bin/check does not yet run it as a step. The domain DSL and component shadowing are unimplemented. Reviewed 2026-08-26.
Rule one — tiers, inside this repo
Imports go down a tier only. Never sideways within a tier, never upward.
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
The table is duplicated in 01-module-map.md and has an executable copy in ../../scripts/lib/tiers.rb. Prose and code must agree; when they diverge, the code is right and the prose is a bug — and MAGIK_DEV_BOUNDARY_TABLE_DRIFT is what says so, because ../../scripts/checks/boundaries.rb re-parses both documents against the code on every run.
| Rule | Detail |
|---|---|
| Down only | action (3) may require model (2). model may not require action. |
| No sideways | render may not require realtime; they are both tier 2. |
| Front door only | a subsystem requires magik/<other>, never magik/<other>/internal_file. Reaching past the front door is a violation even when the tier allows the subsystem. |
| No exceptions table | the first sideways exception means a tier is wrong. Fix the tier. |
The edges that shaped the table
Each of these was the tempting sideways import, and the design that removed the need for it:
| Tempting edge | Why it is refused | What happens instead |
|---|---|---|
render → realtime |
live :orders, on: "orders:{tenant}" is rendered as an attribute, so render needs a channel name, not a transport |
the channel-name vocabulary is declared in core; both sides read it from there |
core → check |
boot has to run guardrails, so core looks like it needs the rule set |
core owns the rule interface and the runner; each rule ships in the subsystem that owns the concept; check (5) owns the static set (01-module-map.md) |
schema → model |
a migration generator wants to read model declarations | the generator lives in cli (6) and passes a declaration snapshot into schema as a value |
api → action |
REST resources look like actions | api resources are generated over model + router; an app's own callbacks are blocks it supplies |
jobs → notify |
notifications deliver through the queue | inverted: notify (3) requires jobs (2) and enqueues |
model → render |
serialization of a :money field for the admin |
model exposes typed values; render and api decide presentation |
render → auth |
a screen has to know whether this actor may see it | refused, and this is the edge that fixes policy's tier. The decision is policy's and policy is tier 1; the actor is auth's and arrives as an opaque value the request already resolved. render (2) requires policy (1) and never auth (3) |
schema sits at tier 1, not beside core at 0, because it requires core for error codes and column value types. Colloquially both are "the bottom"; the table records the actual direction, so no exception line is needed.
policy sits at tier 1, not beside auth at 3, and the reason is the same rule applied harder. render and realtime are tier 2 and both must evaluate policies; imports go strictly down, so an evaluator at tier 3 is 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 — and auth at tier 3 supplies that value and never decides. The full argument: 01-module-map.md.
How it is enforced
../../scripts/checks/boundaries.rb, run directly today and a bin/check step once one is wired:
1. read every lib/magik/**/*.rb
2. scan require / require_relative statements and constant references (regex over source — no app boot needed)
3. resolve specifier → owning subsystem, or "internal file of another subsystem"
4. evaluate tier(importer) > tier(imported)? front door only?
5. report one finding per violation: file:line, both subsystems, both tiers, allowed set
| Property | Detail |
|---|---|
| Codes | MAGIK_DEV_BOUNDARY_TIER (sideways or upward) · MAGIK_DEV_BOUNDARY_INTERNAL_REQUIRE (past the front door) · MAGIK_DEV_BOUNDARY_TABLE_DRIFT (a document restates the table and no longer agrees with the code). Repo-internal, because a check is something only this checkout can run — the framework codes MAGIK_BOUNDARY_TIER and MAGIK_BOUNDARY_INTERNAL_REQUIRE stay reserved for the magik check that will raise them inside somebody's application (03-error-codes.md) |
| Cost | a source scan, no boot, no database |
| Where it runs | ruby scripts/checks/boundaries.rb, and CI. Not in magik server — a tier violation is a repo defect, not an app one |
--json |
one finding object per violation, same schema as every other check (03-error-codes.md) |
The third code is the one that keeps this page honest: the table is written three times — here, in 01-module-map.md, and in scripts/lib/tiers.rb — and only the third is authoritative.
Rule two — domains, inside an app
Spec item 12. The same idea one level up: an app's own modules, with a boundary the boot enforces.
# domains/billing/domain.rb
domain :Billing do
depends_on :Accounts
exposes :InvoiceSummary, :charge_customer
publishes_events :invoice_paid, :invoice_failed
end
domains/
billing/
domain.rb # the declaration above
app/ # the SAME tree as a flat app, one level in
models/ # owned by Billing — private unless exposed
screens/
actions/
components/ # domain-scoped component overrides
test/
accounts/
domain.rb
app/
test/
A domain contains the same app/ tree as a flat app — app/models/invoice.rb becomes domains/billing/app/models/invoice.rb and nothing inside the file changes. The prescribed layout is ../../wiki/Project-Layout.md, demonstrated by ../../dummy/; a generator that invents a path is a bug.
| Declaration | Meaning |
|---|---|
depends_on |
the domains this one may use. Undeclared use fails at boot, even if exposes would have allowed it. |
exposes |
the only constants and methods other domains may reach — the domain's public surface. |
publishes_events |
the events other domains may subscribe to. The decoupled path: a subscriber needs no depends_on. |
What fails, and when
| Violation | When | Code |
|---|---|---|
| Referencing another domain's model constant directly | boot | MAGIK_DOMAIN_BOUNDARY |
Using a domain not in depends_on |
boot | MAGIK_DOMAIN_UNDECLARED_DEPENDENCY |
| A dependency cycle between domains | boot | MAGIK_DOMAIN_CYCLE |
| Subscribing to an event no domain publishes | boot | MAGIK_DOMAIN_UNKNOWN_EVENT |
| Two domains declaring the same model | boot | MAGIK_DOMAIN_DUPLICATE_OWNER |
Boot-time is the whole point: a domain boundary enforced by review erodes at exactly the moment the app is large enough to need it. The intended failure shape:
MAGIK_DOMAIN_BOUNDARY domains/billing/app/actions/charge.rb:12
domain :Billing references Accounts::User directly.
Accounts exposes: :AccountSummary, :find_account. Billing depends_on: [:Accounts].
fix: magik check --domains
Legal paths between domains
| Path | Shape | Coupling |
|---|---|---|
| Exposed interface | Accounts.find_account(id) |
compile-time-ish: depends_on required, boot-verified |
| Published event | `on :invoice_paid do | event |
| Shared value types | Magik::Money, Magik::Timestamp — framework types |
none |
| Anything else | — | refused at boot |
An app that declares no domains has exactly one implicit domain, and none of these rules bites. Domains are for the large-app case the spec names; they are not a tax on a small app.
Rule three — component shadowing
Replacing a kit component is a boundary event too: the registry resolves component :Modal to one definition, and every other component composes whatever it resolved to.
| Rule | Detail |
|---|---|
| Resolution order | app/components/ → domains/<name>/components/ → a kit :name gem → the Magik built-in. First match wins, no merging (../idea/08-component-overrides.md). |
| Scope | a domain-scoped override applies inside that domain only; it never changes another domain's rendering. |
| Contract | a replacement declaring satisfies Magik::Kit::X is verified at boot — missing prop, slot, target or event fails with MAGIK_COMPONENT_CONTRACT_VIOLATION. |
| Visibility | magik check prints every shadow and its scope. An override nobody can see is the silent magic the design exists to prevent. |
Why boundaries at all
| Without | With |
|---|---|
| every subsystem eventually requires every other, and the gem cannot be reasoned about or split | the dependency graph is a DAG you can read in one table |
| domain boundaries hold only while the team is small and disciplined | they hold because the app does not boot otherwise |
| a component override changes rendering somewhere nobody looked | the shadow is reported and its contract is verified |