Adding a feature
The loop a contributor or an agent follows to land one piece of the framework, start to finish, with the commands.
Status: partly runnable. bin/check and rake test exist and pass today; the DSL, the option tables, magik describe and the framework's own test runner do not, so the steps that touch them are the intended ones. Where a step cannot run today, the page says so. Reviewed 2026-08-26.
The loop
| # | Step | Command | Lands in |
|---|---|---|---|
| 0 | Read the spec section | — | ../idea/00-build-spec.md |
| 1 | Document it here first | edit | docs/idea/02-dsl-surface.md, docs/architecture/* |
| 2 | Pick the subsystem and confirm the tier | — | 01-module-map.md, 02-boundaries.md |
| 3 | Write the failing Minitest | edit | test/magik/<subsystem>/<concern>_test.rb |
| 4 | Watch it fail | bundle exec rake test TEST=test/magik/<subsystem>/<concern>_test.rb |
red, for the right reason |
| 5 | Implement | edit | lib/magik/<subsystem>/<concern>.rb |
| 6 | Add the option-table rows | edit | the construct's Option rows, which magik describe serializes |
| 7 | Register the error codes | edit | lib/magik/<subsystem>/errors.rb |
| 8 | Ship the guardrail | edit + test | the rule object in the same subsystem |
| 9 | YARD every public method | edit | the same files |
| 10 | Run the gate | bin/check |
green |
| 11 | Changelog | edit | CHANGELOG.md |
Steps 1 and 3 are the two most often skipped and the two that make the difference: a construct with no doc is a construct nobody can call correctly, and an implementation written before its test is an implementation whose test is written to pass.
0. Read the spec section
The spec is the source of truth, and no framework code ships that is not spec-backed (00-conventions.md). Name the section in the commit message:
model: field :money refuses floats (spec: Non-negotiable #10, Phase 1)
If the thing you want is not in the spec, the first commit edits the spec, on its own, with the argument. Inventing behaviour in lib/ and back-filling a doc is how a framework acquires surface nobody chose.
1. Document it first
Write the intended shape before the implementation, so the design argument happens in prose where it is cheap:
| What you are adding | Document it in |
|---|---|
| a DSL construct or an option | ../idea/02-dsl-surface.md |
| a boot check | ../idea/03-guardrails.md + 03-error-codes.md |
| a backend behind a seam | ../idea/04-swap-points.md |
| a new subsystem | 01-module-map.md + a tier in 02-boundaries.md |
| a refusal | ../idea/05-limits.md |
The doc is written in the same status vocabulary as everything else: it says planned until the code lands in the same PR, and then it does not.
2. Pick the subsystem
# which subsystem owns this, and what may it require?
sed -n '/^## Every subsystem/,/^## Where the guardrails live/p' docs/architecture/01-module-map.md
| Question | Answer in |
|---|---|
| Which subsystem owns the concept? | 01-module-map.md — one reason to change each |
| What may it require? | 02-boundaries.md — strictly lower tiers, front doors only |
| Does it need something from a higher tier? | then the design is wrong. Move the shared vocabulary down into core, or pass the value in as a parameter |
3–4. Write the failing test
# test/magik/model/money_field_test.rb
# frozen_string_literal: true
require "test_helper"
module Magik
module Model
class MoneyFieldTest < Magik::TestCase
def test_float_assignment_raises_with_the_stable_code
definition = Magik::Model::Definition.new(:Order) { field :total, :money, currency: :usd }
error = assert_raises(Magik::Error) { definition.coerce(:total, 10.5) }
assert_equal "MAGIK_MODEL_FLOAT_MONEY", error.code
end
end
end
end
bundle exec rake test TEST=test/magik/model/money_field_test.rb
| Rule | Detail |
|---|---|
| Assert the code, not the message | messages get reworded; codes are permanent (03-error-codes.md). |
| Red for the right reason | a NameError because the class does not exist yet is a valid first red; a typo is not. |
| One behaviour per test | the test name is the sentence the behaviour makes. |
| Determinism | frozen clock, seeded randomness, no network (04-testing-strategy.md). |
5. Implement
lib/magik/<subsystem>/<concern>.rb
| Rule | Detail |
|---|---|
| SRP | one object, one reason to change. Under 150 lines, ≤ 7 public methods, ≤ 4 collaborators (00-conventions.md). |
| Requires | require_relative inside the subsystem, require "magik/<other>" across — front door only, strictly lower tier. |
| Frozen | freeze what is registered; no mutable state after boot, because the parallel test runner depends on it. |
| Stubs | anything you are not implementing raises NotImplementedError naming the spec phase. Never a fake that returns a plausible value. |
6. Add the option-table rows
Every option a construct accepts is a row in the option table, and that table is the one representation the coercer, the guardrails, the docs anchors and magik describe all read (R8, 00-conventions.md). An option accepted by the coercer and missing from the table is not a shortcut; it is an option nobody can look up, which is an R6 defect.
| The row carries | Because |
|---|---|
| name, type, default | R3 — every option has a default, so the minimal declaration boots |
| the allowed set, where the space is closed | R2 — a closed set is a Symbol from that set, so a typo is refused at boot and the error can name the choices |
applicability (applies_when, required_when) |
so describe can say values: applies to :enum rather than listing everything unconditionally |
| the doc anchor | the join that lets an agent go schema → prose in one hop (09-shipped-docs.md) |
the MAGIK_* codes this option can raise |
R7 — the error names the option, the allowed set, and a fix: pointing at magik describe |
And two shapes to refuse before writing the row: a lambda in an option (behaviour goes in a block — R9, and a schema cannot describe what an arbitrary if: decides), and a per-name explosion like retention_days: beside retention_hours: (R10 — that is one option taking a :duration, D1).
Neither the option tables nor magik describe exists yet; both land at build-order step 2, and retrofitting them onto constructs already implemented is the migration that ordering exists to avoid.
7. Register the error codes
# lib/magik/model/errors.rb
register "MAGIK_MODEL_FLOAT_MONEY",
summary: "A float reached a :money field.",
why: "Currency in binary floating point loses cents. :money is integer minor units plus a currency.",
example: "field :total, :money, currency: :usd # then: order.total = money(1050, :usd)",
fix: "Use money(cents, :currency) or an Integer of minor units.",
doc: "docs/idea/03-guardrails.md",
stage: :runtime,
severity: :error
A raise with an unregistered code fails the catalogue test. A registered code is permanent from the moment it ships.
8. Ship the guardrail
A construct is not done without its boot check (../idea/06-phases.md). The rule object lives in the same subsystem as the concept it guards; core runs it at boot and check runs the static ones without a server (01-module-map.md).
Two tests, always: one app declaration that violates the rule and fails with the code, one that satisfies it and boots.
If what you added is a surface that reaches a model — a screen, an action, an API resource, a channel, a job, an admin panel — it takes a policy: verb, and a declaration with neither a verb nor an explicit policy: :public / policy: :system fails the boot with MAGIK_POLICY_UNDECLARED. That is decision 13, authorization is evaluated in exactly one place, and it is the reason policy sits at tier 1 (01-module-map.md). The generated authorization test — one per verb, including a cross-tenant denial — comes with it (04-testing-strategy.md).
9. YARD
# Coerces a value into this field's declared type.
#
# @param field [Symbol] the field name
# @param value [Object] the incoming value
# @return [Object] the coerced value
# @raise [Magik::Error] +MAGIK_MODEL_FLOAT_MONEY+ when a Float reaches a +:money+ field
# @example
# definition.coerce(:total, 1050) #=> #<Magik::Money 1050 usd>
def coerce(field, value)
Every public method: summary, @param, @return, @raise for each code it can produce. Every DSL entry point additionally an @example matching ../idea/02-dsl-surface.md.
bundle exec yard doc # build
bundle exec yard stats --list-undoc # what is missing
10. Run the gate
bin/check
bin/check exists and runs today. Re-derive its steps rather than trusting a list here:
bin/check --list # the steps it actually runs
bin/check --only test # one of them
bin/check --json # the same run, as data
Two families of check, and only the first is wired into the gate so far:
| Family | Where it lives | Status |
|---|---|---|
| lint, tests, YARD coverage, gem build, the CLI's own surface, docs packaging | bin/check steps |
runs today |
| boundaries, error-code catalogue, changelog, version consistency, doc commands, spec-only drift | one file each in ../../scripts/checks/, run directly — ruby scripts/checks/<name>.rb |
runs today, not yet a bin/check step |
conformance — swap-point suites, where the change touched a seam (../idea/04-swap-points.md) |
nowhere yet | planned; there is no seam to conform to |
The boundaries check is the one to run by hand after touching lib/: ruby scripts/checks/boundaries.rb.
11. Changelog
### Added
- `model`: `:money` fields refuse Float assignment with `MAGIK_MODEL_FLOAT_MONEY` (spec: Non-negotiable #10).
| Rule | Detail |
|---|---|
| Same commit | the change and its changelog entry land together. |
| Honest verbs | "Added", "Changed", "Fixed". Never "improved" or "optimized" without a number and the command that produced it. |
| Status moves too | if this completes a phase item, update ../idea/06-phases.md and drop the planned marker on that row — only for the part that actually landed. |
Definition of done
| # | |
|---|---|
| 1 | The spec section is named in the commit. |
| 2 | The docs describe the shape, and no longer say planned for what landed. |
| 3 | A test fails without the change and passes with it, asserting on codes rather than messages. |
| 4 | The guardrail exists and has both its tests. |
| 5 | Every public method has YARD, with @example on DSL entry points. |
| 6 | bin/check is green. |
| 7 | CHANGELOG.md says what changed. |