Error codes

The design of the MAGIK_* catalogue: the code format, the fields every error carries, how magik errors explain reads it, and the seed table implied by the guardrails.

Status: planned. No error class, no catalogue entry and no magik errors command exists. Every code below is a reserved name, not a shipped one. Reviewed 2026-08-26.

Why codes

An error is the framework's only chance to be useful at the moment something is wrong, and its audience is usually an agent (../idea/07-ai-first.md). A stable code is what makes an error searchable, matchable in a log pipeline, and answerable without reading framework source. A fix: line is what makes it actionable without a human.

Errors are instructions. Prose advice is something a reader has to interpret; a command is something it can run.

Code format

MAGIK_<SUBSYSTEM>_<CONDITION>

There is one rule, and it fits on a line:

A MAGIK_ code is either a framework code, whose token comes from the closed set and which an app author can hit — or a repo-internal code under MAGIK_DEV_*, emitted by this repository's own scripts and checks, which ships to nobody. There is no third kind.

Everything below is that sentence in detail. The rest of this page is about the first kind; the DEV namespace is the second, and it is not a footnote — it is the other half.

Rule Detail
Prefix MAGIK_, always.
Subsystem one token from the closed set below, or DEV for a repo-internal code. Not an open-ended list — an unlisted token is a malformed code, and the check refuses it.
Condition what is wrong, not what to do — UNBALANCED, FORBIDDEN_FIELD, PATH_CONFLICT.
Case SCREAMING_SNAKE.
One code per condition never one code for a family. magik errors explain should answer the specific mistake, not a category.
Stability a shipped code is stable forever. Rewording the message is fine; renaming the code is a breaking change — and the one time that promise was weighed against this format, the reasoning is recorded below.
Registration lib/magik/<subsystem>/errors.rb, one catalogue entry per code. A raise with an unregistered code fails the catalogue test.

The allowed <SUBSYSTEM> tokens

A closed set of 30, for framework codes. These are the tokens a code an app author can hit may use, and they may be written anywhere such a code is written. DEV is deliberately not a thirty-first peer in this table: it is the other half of the rule above, naming everything this repository emits about itself, and it may not appear in an app-facing catalogue — its own section is where it lives. MAGIK_ERROR is the one carve-out, and it is a rule with exactly one satisfying object rather than an exception.

It is written down rather than described because the first version of this page described it — "the owning subsystem, uppercased" followed by eight examples and a dash — and wiki/Error-Codes.md drifted to 82 codes in a looser MAGIK_<CONDITION> shape underneath it, sixteen of them a second name for a guardrail the spec had already named. An open-ended example list is not a rule. This is the rule, and ../../scripts/checks/error_codes.rb enforces it against both catalogues and against bin/ on every run.

Group Tokens Why these
The owning module under lib/magik/, uppercased ACTION ADMIN API AUTH BILLING CHECK CLI CORE DOCS I18N JOBS LEDGER MODEL NOTIFY POLICY PWA REALTIME RENDER ROUTER SCHEMA the twenty-one subsystems of 01-module-map.md, plus docs — a module that ships and backs a ready command, so it owns its own failures
The same, singular where the construct is DOMAIN (subsystem domains) · TEST (subsystem testing) the code names the declaration the author wrote, not the directory it is implemented in. Both spellings are already in the catalogue and both stay
Stages and cross-cutting concerns owned by core or check BOOT CONFIG SCALE BOUNDARY each names when or what kind, not a subsystem: a boot-pipeline refusal, a configuration value, a --scale warning, an internal-require violation. MAGIK_CORE_* would hide which of the four it is
Constructs that outlive their owner's name COMPONENT LAYOUT WEBHOOK FLOW each is a declaration in ../idea/02-dsl-surface.md whose owning subsystem's name would bury it — MAGIK_RENDER_MISSING says nothing, MAGIK_LAYOUT_MISSING says everything
The root, exactly one code MAGIK_ERROR below

Not in the table, because it is not one of the thirty: DEV, the token every repo-internal code carries. bin/ and scripts/ ship to nobody and can never reach an app author, so their failures are not framework failures — but they are written in the same CODE: cause / fix: shape and are searched for the same way. Below.

Two rules follow from the set being closed:

Rule Detail
Adding a token is an architecture change it needs a row above with its reason, and the same edit in scripts/checks/error_codes.rb. A code is not free to invent its own namespace — and if the thing that raises it is a script in this repository rather than the framework, the answer is MAGIK_DEV_*, not a new token
A subsystem may own several tokens; a token is owned by one subsystem core answers to BOOT, CONFIG and CORE; check to CHECK, SCALE and BOUNDARY. The reverse — two subsystems sharing CONFIG — is the ambiguity the set exists to prevent

Required fields

Every error is a Magik::Error subclass carrying:

Field Type Rule
code String the stable identifier above
cause String the specific fact, with the real identifiers. Never a restatement of the code
location String file:line in app code — the declaration site, not a framework frame
fix String an executable command wherever one exists; advice only where none can
stage Symbol :boot, :check, :runtime — where this condition is detected
severity Symbol :error (refuses) or :warning (reports, e.g. --scale)
doc String the docs path that explains the rule
details Hash structured specifics for --json — the two totals, the two domains, the conflicting paths
raise Magik::Ledger::UnbalancedEntry.new(
  code:     "MAGIK_LEDGER_UNBALANCED",
  cause:    "entry :refund debits [:refunds] and credits [:cash, :fees]",
  location: "app/ledgers/payments.rb:14",
  fix:      "magik errors explain MAGIK_LEDGER_UNBALANCED",
  stage:    :boot,
  severity: :error,
  doc:      "docs/idea/03-guardrails.md",
  details:  { ledger: "Payments", entry: "refund", debits: ["refunds"], credits: %w[cash fees] }
)

Three renderings, one object

Rendering Audience Shape
Terminal a human at a CLI code, location, cause indented, fix: line, then the boot summary
--json an agent, CI, a log pipeline the full field set, one object per finding, stable schema
Dev error page a human in a browser the same code, cause and fix:, plus the app frame — never a raw 40-frame backtrace (06-observability.md)

The rule: the same code and the same fix: string in all three. A rendering that paraphrases is a rendering an agent cannot match on.

magik errors explain <CODE>

The catalogue as a command. Planned shape:

magik errors explain MAGIK_LEDGER_UNBALANCED
magik errors explain MAGIK_LEDGER_UNBALANCED --json
magik errors list --subsystem ledger --json

Text output: what the rule is, why it exists, the shortest failing example, the fix, and the doc link.

--json output, one stable object:

{
  "code": "MAGIK_LEDGER_UNBALANCED",
  "subsystem": "ledger",
  "stage": "boot",
  "severity": "error",
  "summary": "A ledger entry's debits do not equal its credits.",
  "why": "Double-entry only holds if every entry balances; an unbalanced entry corrupts every downstream total.",
  "fix": "Make the debit lines and credit lines of the entry sum to the same amount.",
  "example": "entry :refund do |amount:|\n  debit :refunds, amount\n  credit :cash, amount\nend",
  "doc": "docs/idea/03-guardrails.md",
  "since": "unreleased"
}
Property Rule
Source the same registry the raise reads. There is no second copy of the catalogue to drift.
Offline no network, no database, no app boot — an agent can call it mid-edit.
Schema stability the --json field set is additive-only across versions. Removing or renaming a field is a breaking change.
Coverage a catalogue test asserts every Magik::Error subclass has an entry, and every entry has a fix and a doc that resolves.

Seed catalogue

Every code implied by ../idea/03-guardrails.md and the spec's architecture decisions. All planned — none is registered.

Code Subsystem Stage Severity Condition
MAGIK_BOOT_REDEFINED core boot error a second App.define in one process
MAGIK_BOOT_GUARDRAIL_FAILED core boot error the aggregate failure that aborts the boot after individual findings
MAGIK_CONFIG_UNKNOWN_BACKEND core boot error use :seam, :name naming a backend that does not ship
MAGIK_CONFIG_MISSING_KEY core boot error a declared-required config key with no value (07-configuration-and-secrets.md)
MAGIK_MODEL_FORBIDDEN_FIELD model boot error field :card_number or another PAN-shaped field
MAGIK_MODEL_FLOAT_MONEY model boot · runtime error a float reaching a :money field
MAGIK_MODEL_NO_TENANT model boot error a model with no tenant_id and no declared reason
MAGIK_MODEL_IMMUTABLE_VIOLATION model runtime error a write past immutable_after:
MAGIK_MODEL_UNCONSTRAINED_UPLOAD model boot error a :file field or attachment with no max_size and no content_types — an unbounded upload field is an unbounded storage bill and a trivial DoS
MAGIK_SCHEMA_IRREVERSIBLE schema boot error a migrate with no down
MAGIK_SCHEMA_DRIFT schema boot error a declared field with no applied migration behind it
MAGIK_ROUTER_PATH_CONFLICT router boot error two declarations compiling to one path
MAGIK_POLICY_UNDECLARED policy boot error a screen, action, api resource, channel, job or admin_panel with no policy: and no explicit policy: :public / policy: :system
MAGIK_POLICY_IO policy boot error a can block issuing a query — live re-evaluates one per subscriber per change, so a query here is one round trip per row per open socket
MAGIK_POLICY_UNKNOWN_VERB policy boot error policy: %i[Invoice publish] where policy :Invoice declares no :publish
MAGIK_POLICY_NO_DEFAULT policy boot error a policy block with no default :deny
MAGIK_POLICY_NULL_PASSES policy boot error a row-level rule that would pass on an absent record
MAGIK_RENDER_SCREEN_STATEFUL render boot error a screen holding state across requests
MAGIK_RENDER_TIMESTAMP_NO_ZONE render boot error a timestamp rendered with no explicit zone
MAGIK_COMPONENT_CONTRACT_VIOLATION render boot error a replacement component missing a contract prop, slot, target or event
MAGIK_LAYOUT_MISSING render boot error a screen with no layout and no layout: :None
MAGIK_LAYOUT_UNKNOWN_SCREEN render boot error a nav_item naming a screen that does not exist
MAGIK_ACTION_STATEFUL action boot error an action holding state across requests
MAGIK_ACTION_IDEMPOTENCY_REQUIRED action boot error a money-posting action with no idempotent_by:
MAGIK_LEDGER_UNBALANCED ledger boot error debits ≠ credits in an entry
MAGIK_LEDGER_ENTRY_MUTATED ledger runtime error an update or delete reaching a posted entry
MAGIK_JOBS_NO_PERFORM jobs boot error a job declaring no perform
MAGIK_WEBHOOK_UNVERIFIED api boot error an inbound webhook with no verify_signature
MAGIK_ADMIN_INLINE_MUTATION admin boot error an admin_panel declaring a write path that is not one of the app's own actions — a Magik admin cannot have a mutation the product does not have
MAGIK_PWA_OFFLINE_UNSUPPORTED pwa boot error an offline strategy or app-data cache requested
MAGIK_I18N_MISSING_KEY i18n check error a t() key missing from a shipped locale
MAGIK_DOMAIN_BOUNDARY domains boot error a direct cross-domain model reference
MAGIK_DOMAIN_UNDECLARED_DEPENDENCY domains boot error using a domain absent from depends_on
MAGIK_DOMAIN_CYCLE domains boot error a dependency cycle between domains
MAGIK_DOMAIN_UNKNOWN_EVENT domains boot error subscribing to an unpublished event
MAGIK_DOMAIN_DUPLICATE_OWNER domains boot error two domains claiming one model
MAGIK_SCALE_UNSCOPED_QUERY check check warning a query site with no tenant_id in WHERE
MAGIK_BOUNDARY_TIER check check error a sideways or upward require inside lib/magik/ (02-boundaries.md)
MAGIK_BOUNDARY_INTERNAL_REQUIRE check check error requiring past another subsystem's front door
MAGIK_CLI_HARNESS_STALE cli check warning a generated app's framework block was written by an older magik than the one installed — fix: magik generate agents --update
MAGIK_CLI_HARNESS_MARKERS_MISSING cli check error the magik:framework-block markers a regeneration needs were removed by hand, so an update refuses rather than guessing where the boundary was

One code, every surface. MAGIK_POLICY_UNDECLARED is the load-bearing row, and it is deliberately owned by policy rather than by each surface that can trip it. There is no admin-specific "unprotected panel" code, no channel-specific one and no job-specific one: an admin_panel with no policy: fails the boot under the same code as a screen with no policy:, because it is the same mistake. A per-surface family would be exactly the "one code for a category" the format rules above refuse, in reverse — and the point of the guardrail is that authorization is non-optional the way tenant_id is, not that the admin panel is a special case.

The last two rows are reserved and proposed, not implemented — they are named by ../idea/09-app-scaffold.md so the names cannot be taken twice. Neither the magik check that would report a stale harness nor the magik generate agents --update that would fix one exists.

Both were first written as MAGIK_HARNESS_STALE and MAGIK_HARNESS_MARKERS_MISSING, which sat outside the MAGIK_<SUBSYSTEM>_<CONDITION> shape above, because HARNESS is not a subsystem. Decided 2026-08-26: they conform. The subsystem column already said cli, the thing that repairs both is a CLI command, and the harness is not a subsystem of its own — so the names became MAGIK_CLI_HARNESS_STALE and MAGIK_CLI_HARNESS_MARKERS_MISSING, and HARNESS was never added to the token set. The same reasoning as the decision record below, applied one step earlier: a reserved name is free to move, a registered one is not. Both old spellings are in RETIRED in ../../scripts/checks/error_codes.rb, so neither can come back.

The shipped codes and the format: a decision record

0.0.1 ships eight codes, and three of them did not fit MAGIK_<SUBSYSTEM>_<CONDITION>. Because the stability rule above says a shipped code is stable forever, the question was whether the format gets a permanent exception or the codes get renamed. Decided 2026-08-26: renamed.

Code Verdict Reason
MAGIK_UNKNOWN_COMMAND → MAGIK_CLI_UNKNOWN_COMMAND renamed raised by Magik::CLI; CLI is its owning module
MAGIK_COMMAND_NOT_IMPLEMENTED → MAGIK_CLI_COMMAND_NOT_IMPLEMENTED renamed as above
MAGIK_INVALID_OPTION → MAGIK_CLI_INVALID_OPTION renamed as above
the four MAGIK_DOCS_* kept lib/magik/docs.rb is a real module behind a ready command. DOCS is the owning module, so these already fit — and MAGIK_CLI_DOCS_PAGE_NOT_FOUND would name the caller rather than the owner
MAGIK_ERROR kept, as a rule see below

The principle. A stability promise protects consumers — their rescue clauses, their log matchers, their fix: scripts. 0.0.1 is a RubyGems name reservation with no implementation (CHANGELOG.md), so there is no consumer to protect and nothing the rename can break. Renaming now costs one changelog line. Renaming after adoption is a breaking change forever, and not renaming carves a permanent exception into the one convention the whole catalogue rests on. The window to fix a convention is before there is anyone to break.

MAGIK_ERROR is the root code, not an exception

Magik::Error is the base class. It names no subsystem and no condition because it has neither: it is what a one-off raise carries when no more specific class exists, and a reader who sees it should read "unclassified", not "core failed". MAGIK_CORE_ERROR would assert a subsystem that did not raise it.

So the rule, which the check encodes rather than allowlisting: exactly one code may be MAGIK_ERROR, and it must belong to Magik::Error itself. A second class claiming it, or Magik::Error claiming anything else, is a finding. That is narrower than an exception list — an exception list grows, and a rule with one satisfying object cannot.

The DEV namespace: codes that never reach an app

This is the other half of the one rule, not a footnote to it.

A MAGIK_ code is either a framework code, whose token comes from the closed set and which an app author can hit — or a repo-internal code under MAGIK_DEV_*, emitted by this repository's own scripts and checks, which ships to nobody. There is no third kind.

Two things in this repository emit the second kind, and they have one audience between them — a contributor or an agent working in this checkout:

Emitter What it is Example
bin/ the developer scripts — setup, check, dev, rake, release, console MAGIK_DEV_NO_RAKE from bin/rake
scripts/checks/ the gate's own findings — every Finding.new(code: ...) this repository raises about itself MAGIK_DEV_MANIFEST_DRIFT from scripts/checks/manifest.rb

Neither ships: they are not in the gem, they are not generated into an app, and no app author can reach one. Their failures are therefore not framework failures, and they are not in ../../wiki/Error-Codes.md, which is the app author's manual and would be lying if it listed them — MAGIK_DEV_ERROR_CODE_PUBLISHED is the finding that says so.

They still carry the MAGIK_ prefix, and that is the point. An agent working in this repository reads MAGIK_DEV_NO_RAKE off its terminal and searches for it exactly the way it searches for MAGIK_LEDGER_UNBALANCED — so the string has to be findable, has to be unique, and must not collide with a name the framework will want later. DEV is what keeps the two apart while leaving one search that works for both, and on a red gate the prefix is useful signal in its own right: it says this is about the repository, not about your app.

bin/: the developer scripts

Code Raised by Condition fix:
MAGIK_DEV_NO_RUBY bin/setup no ruby on PATH install Ruby ≥ 3.2, then re-run bin/setup
MAGIK_DEV_RUBY_TOO_OLD bin/setup Ruby below the gemspec's required_ruby_version install the version named in .ruby-version, then re-run bin/setup
MAGIK_DEV_NO_RAKE bin/rake rake is not installed gem install rake
MAGIK_DEV_NO_WATCHER bin/dev no file watcher on the machine, so --watch has nothing to watch with install watchexec, entr, fswatch or inotify-tools
MAGIK_DEV_NO_VERSION bin/release lib/magik/version.rb defines no Magik::VERSION set VERSION to a MAJOR.MINOR.PATCH string in that file

scripts/checks/: the gate's own findings

Do not read this list for a verdict — run for check in scripts/checks/*.rb; do ruby "$check" --help; done, which prints each check's codes from the header that is the only copy of them. The families, and where each is declared:

Family Check About
MAGIK_DEV_BOUNDARY_* boundaries a sideways or upward dependency inside lib/magik/, a require past a front door, a tier table a document no longer agrees with
MAGIK_DEV_ERROR_CODE_* · MAGIK_DEV_ERROR_FIX_ADVICE error-codes this page's rules, over all four corpora
MAGIK_DEV_SPEC_ONLY_* spec-only-drift Magik::SPEC_ONLY_SUBSYSTEMS disagreeing with what the modules do
MAGIK_DEV_DOC_* doc-commands a command in a fenced block that this repository does not provide
MAGIK_DEV_CHANGELOG_* changelog CHANGELOG.md drifting from Keep a Changelog
MAGIK_DEV_VERSION_* version-consistency a copy of the version disagreeing with lib/magik/version.rb
MAGIK_DEV_DUMMY_PARSE_ERROR dummy-parses a file under dummy/ that is not valid Ruby
MAGIK_DEV_MANIFEST_* manifest magik.manifest.json no longer describing the tree
MAGIK_DEV_CHECK_NOTHING_SCANNED every check, via Check#nothing_scanned a corpus that matched nothing, which reads exactly like a clean tree

These were a fourth namespace and nothing checked it. Until 2026-08-26 they used seven tokens nothing had blessed — MANIFEST, CHANGELOG, VERSION, SPEC, DUMMY, DOC, ERROR_CODE — and the repository ran four MAGIK_ namespaces under three rules. The alternative to folding them in was blessing those seven, which dilutes a closed set of framework tokens with repository-internal concerns and leaves the rule unstatable. Decided: they all became MAGIK_DEV_*. They are reserved names in the strict sense the decision record uses — nobody outside this repository could ever have matched on one — so the rename cost a changelog line.

Two names the rename deliberately left alone. MAGIK_BOUNDARY_TIER and MAGIK_BOUNDARY_INTERNAL_REQUIRE stay in the seed catalogue above as framework codes: they are what magik check will raise inside somebody's application, and scripts/checks/boundaries.rb raising MAGIK_DEV_BOUNDARY_TIER about this tree is a different condition detected by a different program. One name each, and neither borrows the other's.

The rules, unchanged

Everything the rest of this page states applies: one code per condition, a cause naming real identifiers, and a fix: that is a command. ../../scripts/checks/error_codes.rb asserts all three over both emitters, and deliberately does not ask the wiki for a row — it refuses one:

Finding Condition
MAGIK_DEV_ERROR_CODE_BIN_MALFORMED a code bin/ prints that is not MAGIK_DEV_<CONDITION>
MAGIK_DEV_ERROR_CODE_BIN_UNFIXABLE a code bin/ prints with no fix: in the block below it
MAGIK_DEV_ERROR_CODE_FINDING_MALFORMED a finding code under scripts/ that is not MAGIK_DEV_<CONDITION>
MAGIK_DEV_ERROR_CODE_FINDING_FRAMEWORK_TOKEN a finding code claiming a token from the closed set — well-formed, and wrong in the one way that reads as right
MAGIK_DEV_ERROR_CODE_FINDING_UNFIXABLE a Finding.new built with no fix:
MAGIK_DEV_ERROR_CODE_FINDING_UNRESOLVED a code: argument the check cannot read, so the corpus is quietly smaller than it looks
MAGIK_DEV_ERROR_CODE_PUBLISHED a MAGIK_DEV_* code written into wiki/Error-Codes.md or docs/idea/03-guardrails.md
MAGIK_DEV_ERROR_FIX_ADVICE a fix: that tells the reader to think rather than to run something — one phrase list, shared by all four corpora

MAGIK_ is also an environment-variable prefix. MAGIK_YARD_MIN_COVERAGE (bin/check) and MAGIK_DOCS_ROOT (09-shipped-docs.md) are configuration, not codes, and nothing may rename them into the format. A check that grepped for every MAGIK_* string would call both of them malformed codes — which is why the check reads the Code column of a table for the catalogues, the CODE: cause rendering for bin/, and the Finding.new(code: constructor for scripts/, rather than matching the prefix alone.

Option-level errors (R7)

The contract above is extended, not replaced, for the largest class of errors a construct produces: a misspelled or illegal option. R7 (00-conventions.md) requires that such a failure carry the four facts that let an agent fix it without reading framework source — the option it was given, what it probably meant, the legal set, and where to look the rest up.

{ "code": "MAGIK_MODEL_UNKNOWN_OPTION",
  "cause": "field :status, :enum was given `options:`; the option is spelled `values:`",
  "location": "app/models/order.rb:4",
  "stage": "boot",
  "severity": "error",
  "details": { "construct": "model", "declaration": "field", "option": "options",
               "given": "options", "did_you_mean": "values",
               "allowed": ["required","unique","default","values","currency","translatable"] },
  "fix": "magik describe model.field --json" }
Rule Detail
The fix: points at magik describe not at prose, and not at this page. magik describe answers about the grammar, so it needs no app, no boot and no database and can be run mid-edit (01-module-map.md)
allowed comes from the option table the same rows the coercer just rejected the value against. There is no second list to drift (R8)
did_you_mean is best-effort omitted rather than guessed when nothing is close. A wrong suggestion costs more than none
The error is what teaches the lookup an agent that has seen one of these knows magik describe exists. That is the mechanism, and it is why the fix: line may not be softened into advice

magik describe is a phase-1 command and is not implemented; neither is any option table. This section is the contract those errors will be written against.

Adding a code

# Step
1 Confirm it is a distinct condition, not a variation of an existing one.
2 Register it in lib/magik/<subsystem>/errors.rb with summary, why, example, fix, doc.
3 Raise it from exactly one place, with real identifiers in cause.
4 Write the test that triggers it and asserts the code — not the message text.
5 Add the row to this page and to ../idea/03-guardrails.md if it is a guardrail.
6 Record it in CHANGELOG.md. It is now permanent.