The coding contract

The rules every Ruby file in this repo obeys, and the design rules the framework asks its users to obey too.

Status: mostly planned. lib/ holds Magik::Error, the CLI's implemented commands and one spec-only stub per subsystem; no DSL construct exists, and most checks named here are checks somebody still has to write. Re-derive what is a stub with grep -rln NotImplementedError lib/magik. Reviewed 2026-08-26.

Rationale lives in ../idea/01-thesis.md. This file is the rules.

Axioms, applied to code

Axiom What it means while you are typing
The primary developer is an agent one obvious way to do the thing, and a check that says whether you did it
One DSL grammar a new construct that does not read like the existing ones is a rejected design, not a style nit
Guardrails are the product shipping a construct means shipping its boot check
Errors are instructions every raise carries a MAGIK_* code, a cause with real identifiers, and a runnable fix:
Cost is opt-in a feature nobody declared costs zero queries, zero sockets, zero rows
Swaps ship proven a backend is listed only when its conformance suite is green in CI

No framework code that is not spec-backed

The single most important rule in this repo while it is empty.

Rule Detail
Every construct traces to the spec a PR adding DSL, a CLI command or a guardrail names the ../idea/00-build-spec.md section it implements.
The spec wins where a doc and the spec disagree, the doc is wrong and gets fixed.
Extending the spec is a spec change a genuinely new idea edits the spec first, in its own commit, with the argument. Inventing behaviour in lib/ and documenting it afterwards is how a framework acquires surface nobody chose.
Stubs over fakes an unimplemented method raises NotImplementedError naming the spec section and the phase. A plausible-looking fake is worse than an empty file.
# @raise [NotImplementedError] always — spec Phase 3, not implemented
def broadcast(channel, event, payload)
  raise NotImplementedError, "Magik::Realtime#broadcast — spec Phase 3, not implemented"
end

SOLID, and single responsibility above all

SOLID is a written contract here, in two places: the framework's internals, and the app code Magik's users (and their agents) write. The second is the harder claim, and it is the one the DSL is shaped around — following SRP in a Magik app should be the path of least resistance, not an act of discipline.

SRP, concretely for Ruby

One class, one reason to change. In this repo that means many small objects under lib/magik/<subsystem>/, each with a name that says what it does and a public surface you can hold in your head.

The smell, and the split:

Smell Why it happens The split
A 400-line Magik::Model that validates, queries, serializes and renders the DSL entry point becomes the dumping ground for everything the DSL touches Model::Definition (the declaration), Model::Validator, Model::Dataset, Model::Serializer — the DSL method is a thin front door that builds a Definition
A Compiler that parses, plans and emits three reasons to change wearing one name Compiler::Parser → Compiler::Plan → Compiler::Emitter, one direction, values between
A class whose name contains Manager, Handler, Service, Util or Helper nobody could name the single responsibility, so they named the absence of one rename it after what it does; if you cannot, it is two objects
A method taking a mode:, kind: or type: flag that selects a branch a second responsibility smuggled in as an argument one object per mode, chosen at the seam
private methods outnumbering public ones three to one a smaller object is hiding inside extract it, give it a name, test it directly

Measurable form, so bin/check can enforce it later. These are the intended thresholds, and the enforcement is planned, not built — today they are review guidance:

Metric Target Hard ceiling Intended enforcement
File length < 150 lines 300 bin/check (planned)
Public methods per class ≤ 7 12 bin/check (planned)
Collaborators (distinct types a class instantiates or calls) ≤ 4 6 bin/check (planned)
Method length < 15 lines 25 RuboCop Metrics/MethodLength
Cyclomatic complexity — RuboCop default RuboCop Metrics/CyclomaticComplexity

A ceiling is not a budget to spend. Crossing one means the split was due earlier.

The other four, in Ruby idiom

Letter In Magik Consequence you can check
Open/closed the swap points are the extension seams (../idea/04-swap-points.md). New behaviour arrives as a new object registered with the framework — never as a new branch in a case inside an existing one. a case statement over backend names in lib/ is a defect. The registry does the dispatch.
Liskov a backend must satisfy the seam's contract test — which is the spec's "a swap ships proven, not promised" rule stated as a type property. one conformance suite per seam, run against every backend in CI. A backend that needs its own special-cased test has broken the seam.
Interface segregation a subsystem depends on the narrow role it uses, not on the whole neighbouring module. render needs a channel name, not the realtime subsystem — so the channel-name vocabulary lives in core and render never requires realtime. the tier rules in 02-boundaries.md. A require that exists to reach one method is a role that belongs lower down.
Dependency inversion subsystems depend on an abstraction the app configures at boot — the DB adapter, the job backend, the realtime backend, the cache. jobs knows a queue role; it does not know Postgres. this is why swap points can exist at all. A subsystem naming a concrete product outside its backends/ directory is a defect.

The same rules apply to app code — including code Magik generates

A generator emits code somebody will read, review and extend, so it obeys this contract exactly as lib/ does. A generator that emits a 400-line god object, or a second write path, has shipped the smell rather than the shape. The DSL is designed so that a Magik app falls into SRP by default:

App-level rule How the framework makes it the default
One declaration per file generators emit it that way; App.define's load walks directories, so adding a file is the whole ceremony
One directory per concern app/models/, app/screens/, app/actions/, app/jobs/, app/ledgers/, domains/<name>/
An action mutates; a job runs async; a ledger touches money the grammar gives each its own construct, so mixing them requires going out of your way
A screen renders and holds nothing the stateless guardrail refuses the alternative at boot (../idea/03-guardrails.md)
A domain owns its models boundary enforcement refuses the reach-across at boot (02-boundaries.md)

The app-side layout and a worked example live in ../../wiki/Project-Layout.md and the ../../dummy/ reference app.

DSL design rules: R1–R10

SOLID governs the objects inside a subsystem. R1–R10 govern the construct those objects put in front of an app author, and they are cited by number across the spec, so they are indexed here rather than restated: the rules are stated in full, with their reasoning, in ../idea/11-dsl-as-tool-surface.md §4, and that page proposes exactly this pointer.

They exist because the DSL is a tool surface: an agent does not recall a grammar, it looks one up, so an option that cannot be looked up may as well not exist. And a DSL option is authored once and read forever — renaming one after release is a breaking change under semver — so the rules apply before the first construct ships, not after.

# Rule The failure it prevents
R1 One concept, one spelling, everywhere an agent generalises from one construct to the next; an idea spelled two ways turns that generalisation into a plausible wrong guess. Sharing a stem while meaning different things is the worst of both
R2 Enumerated beats free-form a closed value set is a Symbol from that set, checkable at boot and completable. A free-form String is permitted only where the space is genuinely open — a URL, a cron expression, a regex — and then it carries a format check and a named error code
R3 Every option has a default; the minimal declaration is valid a construct you cannot write a two-line version of. The exception is stated as a rule, not a loophole: an option with no defensible default is required and enumerated, so the failure names the choices
R4 Verbose is acceptable; unreadable is not a terse flag whose meaning must be looked up. Machine authorship makes length free at write time; it does nothing about legibility at review time. Corollary: do not rename what every ORM and SQL itself already uses — novelty has a review-time cost
R5 Locality is non-negotiable config/ archaeology. A declaration's meaning must be visible without opening another file: routes derive from names, use seams are named once in App.define, everything else is in the block you are reading
R6 Every option is describable by the schema, or it is a defect an accepted-but-unlisted option, or a lambda in an option — invisible to magik describe, so neither the human nor the agent will use it correctly and both will invent something else
R7 An error names the specific cause "invalid value". An option-level failure carries the construct, the declaration, the option, what was given, the did_you_mean and the allowed set (03-error-codes.md)
R8 The option tables are the one representation two tables drift; one cannot. The coercer, the guardrails, the docs anchors and magik describe all read the same rows — one artifact serialized four ways (01-module-map.md)
R9 Behaviour goes in blocks, options go in keyword arguments a positional argument has no name, so it cannot appear in a schema, so it cannot be described — an R6 violation by construction. No positional booleans, ever
R10 Adding a construct is a budget decision; adding an option is not the per-name option explosion — retention_days: beside retention_hours: — and its inverse, an option that changes what the construct is. A construct is paid by every agent in every session; an option is paid only where it is used. A new capability arrives as a factory over an existing construct

The four vocabulary decisions the rules produced

Settled before implementation, because a renamed option is free now and a semver break later. The full reasoning is in ../idea/00-build-spec.md's DSL vocabulary section; this is the shape you write.

# Concept The one spelling From
D1 A duration a :duration type everywhere — a unit-suffixed string coerced at boot: session_ttl "14d", schedule every: "10m", trial: "14d", throttle per: "1h". Never 15.minutes, never a *_days integer R2, R6, R10
D2 A precondition guard "reason" do … end on ledger entry, action, job and flow step. A flow step that simply does not apply uses skip_when do … end — skip and abort are different meanings R9, R7
D3 A field subset fields, filterable, sortable, searchable, writable — five declarations, one vocabulary, identical on api, admin_panel, data_table and screen. per_page: stays an option: a scalar setting is not a field subset R1, R9
D4 Uniqueness they stay apart. unique: on a field is a database constraint and nothing else; a job's dedupe key is idempotent_by, the same word action already uses R1, R10

Enforcement, not prose. A scripts/checks/ step over the option tables asserts that every option in ../idea/02-dsl-surface.md appears in magik describe --json and vice versa. It does not exist yet, and per this repo's own rule a convention with no check does not exist either.

File layout

One gem, subsystems under lib/magik/<subsystem>/. Not a multi-gem monorepo — extraction stays possible, and is not promised.

lib/magik.rb                          # requires core, defines Magik::VERSION entry point
lib/magik/version.rb
lib/magik/<subsystem>.rb              # the subsystem's front door: requires its files, defines its namespace
lib/magik/<subsystem>/errors.rb       # this subsystem's MAGIK_* codes
lib/magik/<subsystem>/<concern>.rb    # one responsibility each
lib/magik/<subsystem>/backends/<name>.rb   # only where the subsystem owns a swap point
exe/magik                             # the CLI binary
test/magik/<subsystem>/<concern>_test.rb
Rule Detail
Subsystem names exactly the twenty-one in 01-module-map.md, which are exactly the keys of Magik::SUBSYSTEMS. A new one is an architecture change, not a file.
File names snake_case.rb, matching the class or module they define.
One class per file except a tiny value object used only by its neighbour.
Front door lib/magik/<subsystem>.rb is the only file another subsystem may require. Reaching into lib/magik/model/dataset.rb from lib/magik/api/ is a boundary violation even when the tier allows the subsystem (02-boundaries.md).
require_relative inside a subsystem, require across so the dependency direction is visible in the first ten lines of every file.

Ruby style

Rule Detail
Magic comment # frozen_string_literal: true on every file, first line.
Indentation two spaces, no tabs.
Module nesting nested, not compact — module Magik; module Model; class Definition — so constant lookup is unambiguous.
Naming CamelCase classes and modules, snake_case methods and variables, SCREAMING_SNAKE constants, ?/! used for their conventional meanings only.
Keyword arguments for anything with two or more parameters, and always for booleans. No positional flags.
Frozen state constants holding collections are .freezed. The declaration registry is frozen when boot completes — that is what makes the stateless guardrail checkable, and what keeps a thread-parallel test run honest (04-testing-strategy.md).
Mutable globals none after boot. No $globals, no class-level mutable accumulators outside the registry.
Comments explain why. Never what.
Lint RuboCop plus rubocop-minitest, rubocop-rake, rubocop-performance. The config in the repo root is the arbiter; disabling a cop inline needs a comment naming the reason.

Runtime

Rule Detail
Production target TruffleRuby.
Development floor CRuby ≥ 3.2 (required_ruby_version = ">= 3.2").
TruffleRuby-specific behaviour verified by ruby scripts/probes/runtime.rb and in CI, never asserted from a laptop (12-runtime-verification.md).
Concurrency real, parallel OS threads. TruffleRuby has no GVL, so anything shared between threads must be frozen or synchronised. Do not add mutable shared state to a subsystem without saying how the server and the test runner are meant to survive it.

YARD on every public method

Documentation is a shipping requirement, not a later pass. .yardopts at the root drives it; CI publishes to GitHub Pages.

# Declares a background job.
#
# @param name [Symbol] the job's PascalCase name, e.g. +:SettleBatch+
# @param policy [Symbol, Array(Symbol, Symbol)] the verb this job runs under;
#   +:system+ is the declared opt-out
# @yield the job body — +retries+, +schedule+, +idempotent_by+ and +perform+ declarations
# @return [Magik::Jobs::Definition] the frozen definition, registered with the app
# @raise [Magik::Error] +MAGIK_JOBS_NO_PERFORM+ if the block declares no +perform+
# @raise [Magik::Error] +MAGIK_POLICY_UNDECLARED+ if +policy:+ is absent
# @example
#   job :SettleBatch, policy: :system do
#     retries times: 5, backoff: :exponential
#     schedule every: "10m"
#     idempotent_by :tenant_id
#     perform { |args| Payments.settle(tenant_id: args[:tenant_id]) }
#   end
# @see https://github.com/developerz-ai/magik/blob/main/docs/idea/02-dsl-surface.md
def job(name, policy:, &block)
Rule Detail
Every public method a summary line, @param for each parameter, @return, and @raise for every error code it can produce.
Every DSL entry point additionally an @example showing the canonical shape from ../idea/02-dsl-surface.md.
Private methods documented where the why is not obvious. Not required.
Unimplemented say so, with the phase: @note Not implemented — spec Phase 4.
Enforcement a YARD coverage step in bin/check (planned). Undocumented public methods fail it.

Errors

Never raise a bare RuntimeError, ArgumentError or StandardError. Every raise is a Magik::Error subclass carrying a code, a cause and a fix.

raise Magik::Ledger::UnbalancedEntry.new(
  code:     "MAGIK_LEDGER_UNBALANCED",
  cause:    "entry :refund debits #{debits.inspect} and credits #{credits.inspect}",
  location: "#{file}:#{line}",
  fix:      "magik errors explain MAGIK_LEDGER_UNBALANCED"
)
Rule Detail
Code format MAGIK_ + SUBSYSTEM + _CONDITION, screaming snake.
Stability a shipped code is stable forever. Agents and log pipelines match on it.
Registration in lib/magik/<subsystem>/errors.rb, one catalogue entry per code.
Renderings one object, three outputs: terminal, --json, dev error page.

Full design: 03-error-codes.md. Diagnostics around it: 06-observability.md.

Tests

Minitest and Rake. Never RSpec — a spec decision, and the reasoning is in ../idea/05-limits.md.

Rule Detail
Location test/magik/<subsystem>/<concern>_test.rb, mirroring lib/.
Naming class DefinitionTest < Magik::TestCase, methods test_<what_it_proves>.
Bar every test must be able to fail for a real reason. assert true is worse than no test.
Determinism frozen clock, seeded randomness, no network. An unmocked socket fails the test.
Order tests run in a randomized order and in parallel; a test that depends on another is broken.

Strategy, the runner, and the parallelism design: 04-testing-strategy.md.

Commits and changelog

Rule Detail
Changelog every user-visible change adds a CHANGELOG.md entry in the same commit.
Status vocabulary planned, not implemented, spec only. Never a benchmark number or a passing-test count for behaviour that does not exist.
Dates any claim that can go stale is stamped As of YYYY-MM-DD.
The gate bin/check (planned) — lint, tests, YARD coverage, boundaries, docs. Green means the change is landable.