DSL surface
Every construct in the Magik grammar, in its intended Ruby shape, grouped by the spec phase that introduces it.
Status: spec only — every construct on this page is planned. No DSL method exists in lib/; the code blocks are design targets an implementer works from, not transcripts. Reviewed 2026-08-26.
Vocabulary. Four concepts recur across constructs and each has exactly one spelling — D1 durations, D2 preconditions, D3 field subsets, D4 uniqueness. This page uses them throughout, and a line that shows one carries an inline D<n> marker pointing at the reasoning in 00-build-spec.md's DSL vocabulary section rather than repeating it.
Every Ruby block on this page parses. Re-derive by extracting each `ruby block and running ruby -c over the concatenation. A spelling that has not been parsed has not been designed.
The grammar
One shape, everywhere. A construct is a keyword, a name, and a block of declarations; options are keyword arguments; behaviour is a nested block. That uniformity is axiom 2 of 01-thesis.md and it is what lets an agent that has learned model write a job without reading further.
keyword :Name, option: value do
declaration :name, option: value
behaviour do |args|
# plain Ruby
end
end
| Rule | Detail |
|---|---|
| Names | :PascalCase for things that become constants (model, screen, job, ledger, api, flow, component, test, domain, migrate). :snake_case for things that become methods or routes (action, channel, notification, webhook). |
| Declarations | say what. field, prop, account, plan, step, resource — no side effects, evaluated once at boot. |
| Behaviour blocks | say how. body, perform, up/down, on_create, entry — evaluated per request, per job, per event. |
| Options | keyword arguments only. No positional booleans, ever. |
| Registration | declaring registers into the app registry; the registry is frozen when boot completes, which is what makes the stateless-server guardrail checkable (03-guardrails.md). |
Every construct
| Construct | Phase | Kind | One line | Status |
|---|---|---|---|---|
App.define |
1 | root | the app composition root and the only boot entry point | planned |
model |
1 | declaration | a table, its fields, its associations and its invariants | planned |
migrate |
1 | declaration | a reversible schema change over Sequel migrations | planned |
component |
2 | declaration | a reusable UI fragment compiling to HTML + hx-* |
planned |
screen |
2 | declaration | a page-level component, auto-routed | planned |
action |
2 | behaviour | a mutation handler, auto-wired to an htmx POST | planned |
policy |
2 | declaration | one authorization rule set, evaluated identically in every surface | planned |
layout |
2 | declaration | the application shell — sidebar, topbar, nav, breadcrumbs, responsive collapse | planned |
channel |
3 | declaration | a named realtime topic and its source events | planned |
live |
3 | declaration | opt a single screen state variable into server push | planned |
broadcast |
3 | behaviour | publish an event to a channel | planned |
presence |
3 | declaration | online/cursor tracking on a channel | planned |
job |
4 | declaration | durable background work with retries and a schedule | planned |
attachment |
4b | declaration | a file on a model, with derivatives, validation and signed delivery | planned |
ledger |
5 | declaration | double-entry accounts and entries, balance-checked at boot | planned |
audited / immutable_after |
5 | annotation | automatic audit trail; append-only after a point in time | planned |
idempotent_by |
5 | annotation | dedupe a retried mutation on a caller-supplied key | planned |
flow |
5 | declaration | a multi-step wizard with server-held step state | planned |
api |
6 | declaration | a versioned REST surface over models | planned |
webhook |
6 | declaration | signed inbound and outbound HTTP events | planned |
auth |
7 | declaration | the full auth flow, Rodauth-backed | planned |
billing |
7 | declaration | plans, trials, subscriptions, metered usage | planned |
admin_panel |
7 | declaration | auto CRUD admin for one model | planned |
tenant_by |
7 | declaration | how a request resolves to a tenant | planned |
locales / translatable |
8 | declaration | the locale set and per-field translation | planned |
pwa |
8 | declaration | installable app metadata — no offline caching | planned |
notification |
8 | declaration | one message, several delivery channels | planned |
test |
9 | declaration | a test group compiling to Minitest | planned |
domain |
12 | declaration | a module boundary enforced at boot | planned |
Phase 1 — Foundation
App.define
The composition root. One per app, in config/app.rb. Nothing outside it is loaded implicitly.
App.define :Shop do
database url: ENV.fetch("DATABASE_URL")
tenant_by :subdomain # phase 7 — how a request resolves to a tenant
time_zone "UTC" # storage zone; rendering always names its own
use :cache, :memory # every `use` is a swap point — 04-swap-points.md
use :jobs, :postgres
use :realtime, :postgres
use :search, :postgres
load "app/models", "app/screens", "app/actions", "app/jobs"
end
| Rule | Detail |
|---|---|
| Boot order | use seams resolve → load evaluates declarations → guardrails run → registry frozen → server binds. A guardrail failure aborts before the socket opens. |
use |
the only place a backend is named. App code never mentions Redis, Kafka or Postgres by name. |
| Idempotence | App.define may run exactly once per process; a second call raises MAGIK_BOOT_REDEFINED. |
model
Wraps Sequel. Explicit queries, no lazy-loading magic, tenant_id and a UUIDv7 primary key injected on every model.
model :Order do
field :reference, :string, required: true, unique: true
field :status, :enum, values: %i[draft placed captured refunded], default: :draft
field :total, :money, currency: :usd
field :placed_at, :timestamp
field :notes, :text, translatable: true # phase 8
computed(:outstanding, :money) { total - paid_total }
belongs_to :customer
has_many :line_items, model: :LineItem
validate :reference, format: /\A[A-Z0-9-]{6,}\z/
validate :total, min: money(0, :usd)
scope :recent do
where(status: :placed).order(Sequel.desc(:placed_at))
end
audited # phase 5
immutable_after :placed_at # phase 5
end
| Element | Contract |
|---|---|
field |
name, type, keyword options. Types: :string, :text, :integer, :decimal, :boolean, :money, :duration, :timestamp, :date, :uuid, :json, :enum, :vector, :file. |
:money |
integer minor units plus a currency. A float assigned to one raises rather than rounding (03-guardrails.md). |
:duration |
a unit-suffixed string coerced at boot — "14d", "10m", "90s". A malformed one fails the boot rather than the first use (D1). |
:file |
an attachment, with max_size: and content_types: required — MAGIK_MODEL_UNCONSTRAINED_UPLOAD otherwise. The declaration form, derivatives and delivery are attachment, phase 4b. |
:timestamp |
stored UTC. Rendering without an explicit zone is a boot failure, not a formatting quirk. |
computed |
a derived field: computed(:name, :type) { … }. The parentheses are load-bearing — a brace block binds to the last call, so computed :name, :type { … } would bind to the symbol. |
| Injected | id (UUIDv7), tenant_id, created_at, updated_at. Never declared by hand. |
scope |
returns a Sequel dataset. The tenant filter is applied by the framework; writing it by hand is redundant, omitting it is not possible. |
| Never | HTTP awareness, rendering, authorization decisions, or cross-domain reads (domain). |
migrate
A schema change, named, reversible, over Sequel's migration engine.
migrate :CreateOrders do
up do
create_table :orders do
uuid :id, primary_key: true
uuid :tenant_id, null: false, index: true
String :reference, null: false
String :status, null: false, default: "draft"
Integer :total_cents, null: false
String :total_currency, null: false, size: 3
DateTime :placed_at
DateTime :created_at, null: false
DateTime :updated_at, null: false
index %i[tenant_id placed_at]
end
end
down do
drop_table :orders
end
end
| Rule | Detail |
|---|---|
| Generated, not hand-written | magik generate migration --from-models diffs declarations against the applied ledger. Hand-editing the emitted file is expected; inventing one from scratch is not. |
down required |
a migration with no down fails to load. There is no irreversible! escape hatch in the design. |
| Money columns | one :money field emits *_cents INTEGER + *_currency CHAR(3). Never a numeric or a float. |
CLI (phase 1)
| Command | Does | Status |
|---|---|---|
magik new <app> |
scaffolds an app around a working App.define |
planned |
| `magik generate model\ | screen\ | action\ |
magik console |
boots the app, guardrails included, into IRB | planned |
magik server |
boots and binds Puma, thread-per-request | planned |
magik check |
runs the guardrails with no server (03-guardrails.md) |
planned |
magik describe [construct[.declaration]] [--json] |
the grammar as data — every option, type, default, allowed set and MAGIK_* code. No app, no boot, no database (11-dsl-as-tool-surface.md) |
planned |
Phase 2 — Rendering & actions
component
A reusable UI fragment. Declares its props; the body compiles to HTML with hx-* attributes — no template file, no hand-written markup.
component :OrderRow do
prop :order, model: :Order
body do
row do
cell { text order.reference }
cell { money order.total }
cell { order.placed_at, zone: viewer.time_zone }
cell do
"Refund",
action: :refund_order,
with: { order_id: order.id },
confirm: t("orders.refund.confirm")
end
end
end
end
The component kit ships button, form, field, data_table, modal, toast, card, list, grid, tabs, stat, chart, and the layout primitives sidebar, topbar, nav_item, breadcrumbs, account_menu and dashboard_grid. Theming is design tokens and CSS variables, light and dark; a raw hex in a component is a convention violation, not a compile error.
Every kit component is responsive by construction, and no app-authored declaration is required to make a screen work on a phone. The one place it costs the author a line is data_table on a narrow screen, which becomes a card list rather than a horizontal scroll.
Every kit component is replaceable — tokens, extends:, a shadowing definition in app/components/, or a raw escape hatch — under a contract verified at boot: 08-component-overrides.md.
screen
A page-level component. Auto-routed from its name — :Orders → /orders. State is computed per request; nothing survives the response. Every screen names a policy verb — or policy: :public — and is rendered into a layout, which defaults to :App.
screen :Orders, policy: %i[Order read] do
title { t("orders.title") }
state :orders do
Order.recent.limit(50)
end
body do
card do
heading t("orders.title")
data_table of: :orders, using: :OrderRow, empty: t("orders.none")
end
end
end
action
A mutation. Auto-wired to a POST endpoint derived from its name, invoked by the hx-post a button or form compiled to.
action :refund_order, policy: %i[Order refund] do |params|
order = Order.find!(params[:order_id])
Payments.refund(order: order, amount: order.total) # phase 5 ledger entry
order.update(status: :refunded)
redraw :orders # re-render the state var, swap the fragment
toast t("orders.refunded"), level: :success
end
| Rule | Detail |
|---|---|
| Authorization | one policy: verb, evaluated before the block runs. A mutation with neither a verb nor an explicit policy: :public does not boot (03-guardrails.md). |
| Signature | one params hash, already coerced to the declared field types. |
| Returns | a redraw, a redirect, or a toast — never HTML assembled by hand. |
| Never | hold instance state across requests (boot guardrail), read raw headers, or perform slow work inline — enqueue a job. |
| Phase 5 | idempotent_by: makes a retried call a no-op instead of a double charge. |
policy
Authorization is decided in exactly one place and evaluated identically by a screen, an action, an API resource, a realtime channel, a job and the admin panel — because those surfaces are generated, and an app cannot reach inside a generated surface to add a check (architecture decision 13).
# app/policies/invoice.rb <-> policy :Invoice
policy :Invoice do
default :deny # required. There is no implicit allow
can :read do |actor, invoice|
actor.role?(:viewer, :member, :admin, :owner)
end
can :issue do |actor, invoice|
actor.role?(:admin, :owner) && invoice.status == :draft
end
can :record_payment do |actor, _invoice|
actor.role?(:admin, :owner)
end
can :administer do |actor, _invoice|
actor.staff? && actor.role?(:support_lead)
end
end
The role set is declared once, beside the actor:
App.define :Ledgerline do
roles :owner, :admin, :member, :viewer, default: :member
staff_roles :support, :support_lead # a separate axis — staff are not tenant members
end
Every surface names a verb rather than writing a check:
screen :Invoices, policy: %i[Invoice read]
action :issue_invoice, policy: %i[Invoice issue]
channel :invoices, policy: %i[Invoice read]
admin_panel :Invoice, policy: %i[Invoice administer]
job :DunningSweep, policy: :system # explicit, never implicit
screen :Pricing, policy: :public # opting out is a declaration too
flow :Onboarding, policy: :public # a flow names its own verb, never its steps'
webhook :incoming, acts_as: :service # no actor to carry a role -- see `webhook`
| Rule | Detail |
|---|---|
| Predicates are pure | no queries, no I/O. A live screen re-evaluates one per subscriber per change, so a query here is one round trip per row per open socket |
A nil record denies |
"no record loaded" and "record not found" are the same nil, and absent evidence is a denial |
| The subject is any declared construct | policy :Model is the common case, not the only one. A ledger and a flow own data and are not models, so policy :Receivables is legal — the subject must simply be a name the registry knows (MAGIK_POLICY_UNKNOWN_SUBJECT). Requiring every surface to project onto a model would force a fake model into existence purely to satisfy a guard |
| One evaluator, every surface | the same block answers the HTTP request, the htmx post, the API call, the channel subscription and the admin render |
| Tier | the evaluator sits at tier 1 — below render and realtime, which must call it. It takes the actor as an opaque value; auth at tier 3 supplies it (../architecture/01-module-map.md) |
| Guardrail | Code |
|---|---|
a surface reaching a model with no policy: and no policy: :public |
MAGIK_POLICY_UNDECLARED |
| a predicate that queries | MAGIK_POLICY_IO |
policy: naming a verb the policy does not declare |
MAGIK_POLICY_UNKNOWN_VERB |
a policy block with no default :deny |
MAGIK_POLICY_NO_DEFAULT |
Three surfaces this construct does not yet cover — an incoming webhook (which has no actor, only a verified origin), a surface over a non-model subject such as ledger, and a flow. Each is stated with its evidence in 00-build-spec.md; none blocks phase 2, and each blocks the phase that ships the surface.
| a row rule that would pass on a nil record | MAGIK_POLICY_NULL_PASSES |
MAGIK_POLICY_UNDECLARED is the load-bearing one: it is what makes an admin_panel with no policy a boot failure rather than a documented risk.
layout
The application shell — the thing a screen is rendered into. A screen declares state and body and is auto-routed; the layout is what wraps it, and it is where navigation is declared.
# app/layouts/app.rb <-> layout :App
layout :App do
do
brand { text app_name }
section t("nav.work") do
nav_item :Dashboard, icon: :home
nav_item :Invoices, icon: :receipt, badge: -> { Invoice.overdue.count }
nav_item :Customers, icon: :users
end
section t("nav.settings"), collapsed: true do
nav_item :Members, icon: :users, policy: %i[Member administer]
nav_item :Billing, icon: :card, policy: %i[Subscription administer]
end
end
do
# derived from each screen's `parent:`
search action: :global_search, placeholder: t("nav.search")
items: %i[profile theme sign_out]
end
content { slot :screen }
responsive do
collapses_below: :md, into: :drawer # CSS + one htmx target. No build step
end
end
screen :Invoices, layout: :App, parent: :Dashboard do … end
screen :SignIn, layout: :None do … end # auth screens want no shell
screen :Pricing, layout: :Marketing do … end # a second layout, not a special case
| Rule | Detail |
|---|---|
| A default that exists | magik new generates a working :App layout with the conventional furniture in it. A generated app has a sidebar on its first run |
| Nav cannot rot | nav_item :Invoices names a screen constant; a link to a screen that does not exist fails at boot (MAGIK_LAYOUT_UNKNOWN_SCREEN). The same holds for an action: search action: :global_search above requires that global_search exist, or the boot fails with MAGIK_LAYOUT_UNKNOWN_ACTION. Only screens were guarded at first, which is how the drafted example above came to name an action no app declared |
| Nav respects authorization | policy: on a nav_item hides what the actor cannot reach. Showing a link to a 403 is the most common authorization bug in a SaaS, and it is free when both are declarations |
| Breadcrumbs are derived | from parent:, never typed per page |
| Every screen has one | default :App; opting out is layout: :None, written down |
| It is not a second override system | sidebar, topbar, nav_item, breadcrumbs and account_menu are kit components with contracts, so all four rungs of 08-component-overrides.md apply unchanged |
| Guardrail | Code |
|---|---|
a screen with no layout and no layout: :None |
MAGIK_LAYOUT_MISSING |
a nav_item naming a screen that does not exist |
MAGIK_LAYOUT_UNKNOWN_SCREEN |
data_table
The most-used component in any SaaS. Every capability below is a server-rendered partial swap, not client state — which is why "no SPA framework" costs nothing here.
data_table :invoices, of: :Invoice do
column t("invoices.number"), :number, sortable: true
column t("invoices.customer"), :customer_name, searchable: true
column t("invoices.total"), :total, align: :end
column t("invoices.status"), :status, as: :badge
paginate by: :cursor, per_page: 50 # cursor, not offset — see below
sortable :placed_at, :total # the allowed set; anything else is a 400
searchable :number, :customer_name
filterable :status, :select, values: %i[draft issued paid void]
filterable :placed_at, :date_range
row_action t("invoices.issue"), action: :issue_invoice, when: ->(i) { i.draft? }
bulk_action t("invoices.void"), action: :void_invoice, confirm: true
columns toggleable: true # per-actor preference, applied server-side
exportable :csv # a job + a notification, over the same filter
empty icon: :receipt, text: t("invoices.none"), action: :create_invoice
compact primary: :number, secondary: %i[status total], action: :issue_invoice
end
| Capability | How it works, and why it is not client state |
|---|---|
| Pagination | cursor-based on (sort_key, id). UUIDv7 primary keys are sortable, so a cursor needs no offset scan and page 900 costs what page 1 costs. This is decision 8 paying for itself |
| Sorting | column headers are hx-get links carrying the sort key |
| Search and filters | write to the query string; the swap re-reads it. So a filtered view is shareable and bookmarkable — the property client-side state destroys |
| Bulk selection across pages | encoded in the form as explicit ids, or as a "everything matching this filter" predicate the server re-evaluates. Never an opaque server-held selection — that would violate the stateless guardrail |
| Column visibility | a per-actor preference row. A preference is data, not session state |
| Loading | hx-indicator on the table's own target, compiled from the table's shape. Automatic, not opt-in |
| Narrow screens | compact turns the table into a card list below the breakpoint. Not a horizontal scroll |
chart
state :monthly_revenue do
Invoice.paid.series(:month, on: :issued_on, last: 12).sum(:total)
end
chart :bar, data: :monthly_revenue, y_format: :money, series_colors: :tokens
| Decision | Answer |
|---|---|
| Types shipped | line, bar, stacked_bar, area, pie, sparkline. Anything beyond that is a BI product (05-limits.md) |
| Data source | a state declaration returning a series, like every other component. A chart never queries — otherwise MAGIK_COMPONENT_DIRECT_QUERY acquires its first exception. series is the grouping helper the model DSL owes it |
| Rendering | server-rendered inline SVG. No client charting runtime, no payload, no build step. It themes from CSS variables, works with live (the re-broadcast fragment is the new chart), degrades to a table for screen readers, and prints |
| The cost, stated | no hover tooltips beyond <title>, no zoom, no pan, no legend toggling. Richer interaction is rung 4 of the override ladder — attach a charting library to a server-rendered element, which 08-component-overrides.md already permits |
| Colours | color_series_1..n tokens, never a hard-coded palette, or the chart is the one thing on the page that does not follow dark mode |
Phase 3 — Realtime (opt-in)
Nothing here costs anything until it is declared. A screen with no live is request/response and holds no socket.
channel, live, broadcast, presence
channel :orders, policy: %i[Order read] do
subscribe_to :Order
on_create { |order| broadcast "orders:#{order.tenant_id}", :insert, order }
on_update { |order| broadcast "orders:#{order.tenant_id}", :update, order }
presence tracking: %i[viewer_id cursor]
end
screen :Orders, policy: %i[Order read] do
live :orders, on: "orders:{tenant}" # this line is the entire cost of realtime
state :orders do
Order.recent.limit(50)
end
end
broadcast "channel", :event, payload is also callable directly from an action or a job when the trigger is not a model write.
| Property | Design |
|---|---|
| Transport | Postgres LISTEN/NOTIFY by default, Redis pub/sub by config (04-swap-points.md). |
| Payload | the re-rendered fragment for the live state var, not a JSON diff — the client is htmx, not a store. |
| Authorization | the same path as a normal request. There is no second door to the data; that is the Meteor lesson in 01-thesis.md. |
| Cost | one declaration per screen. No global sync engine, no app-wide socket. |
Phase 4 — Jobs & async
job
job :SettleBatch, policy: :system do
retries times: 5, backoff: :exponential
schedule cron: "0 3 * * *" # or: schedule every: "10m" — a :duration (D1)
idempotent_by :tenant_id # D4: one spelling for "a repeat with this key does nothing new"
perform do |args|
Order.where(tenant_id: args[:tenant_id], status: :captured).each do |order|
Payments.settle(order: order)
end
end
end
SettleBatch.enqueue(tenant_id: tenant.id, run_at: 1.hour.from_now)
Postgres-backed transactional queue (Que-style) by default, so enqueueing inside a transaction that rolls back enqueues nothing. magik worker runs the process; it is horizontally scalable and holds no state the app can see.
Phase 4b — Media
Media spans four phases — the :file field type is phase 1, the upload component is phase 2,
derivative generation and transcoding are phase 4 jobs, and signed delivery is blocked on
policy. It is its own phase because its value arrives only when all four have landed
(00-build-spec.md).
attachment
Files on a model. Ecommerce and marketplaces are two of the four verticals the mission sentence names, and neither is buildable without it.
model :Product do
:hero, :image,
max_size: "10MB",
visibility: :public, # signed URLs not required
direct: true, # presigned, straight to storage
content_types: %w[image/jpeg image/png image/webp] do # sniffed, never trusted
derivative :thumb, resize: [200, 200]
derivative :card, resize: [640, 480], format: :webp
responsive :hero, widths: [640, 1280, 2048] # emits srcset
exif :orientation # everything else is stripped
end
:manual, :document,
max_size: "50MB",
content_types: %w[application/pdf],
visibility: :private # signed, expiring URLs only
:demo, :video, media: :service # transcoded by the media seam
end
| Rule | Detail |
|---|---|
| Direct-to-storage by default | a request server proxying a large upload holds a connection for minutes and contradicts decision 9. Proxied upload is the explicitly-chosen exception for small files |
| Content type is sniffed | from magic bytes. Never from the extension, never from the client header. A .png that is an .svg is stored XSS |
| Limits are required | an attachment with no max_size and no content_types fails boot |
| EXIF is stripped | on ingest, keeping only what is declared. GPS in a user photo that reached storage is already leaked |
| Derivatives are pre-generated | in a job. On-the-fly needs a cache and is a DoS vector when the transform URL is unsigned |
| Private files are policy-checked | a signed URL is issued only after the same policy verb that guards the record. An attachment served without one is a cached cross-tenant leak |
| Video and audio are wrapped, never built | use :media, :mux — the provider ingests, transcodes, packages and serves; the app stores an id. An :ffmpeg backend exists for teams that want no vendor, documented with its costs |
| Blobs have a lifecycle | deleting a record enqueues blob deletion; retention and GDPR erasure cover the blobs, not only the rows |
| Guardrail | Code |
|---|---|
an attachment with no size or content-type bounds |
MAGIK_MODEL_UNCONSTRAINED_UPLOAD |
a :private attachment on a model with no policy |
MAGIK_POLICY_UNDECLARED |
One honest footnote on the client: htmx alone cannot show byte-level progress on a direct-to-storage PUT. The framework ships a small, versioned, non-bundled magik-upload.js in public/ on the same terms as htmx itself — a fixed asset, no toolchain, no build. The app author still writes no JavaScript, which is the claim that matters.
Phase 5 — Money & compliance
ledger
Double-entry, append-only, balance-validated at boot — the flagship guardrail.
ledger :Payments do
account :cash, type: :asset
account :revenue, type: :revenue
account :refunds, type: :contra_revenue
entry :capture do |order:, amount:|
guard "the amount must be positive" do amount.positive? end
guard "only a placed order may be captured" do order.status == :placed end
debit :cash, amount
credit :revenue, amount
end
entry :refund do |order:, amount:|
guard "a refund cannot exceed what was captured" do
amount <= Payments.captured_for(order)
end
debit :refunds, amount
credit :cash, amount
end
end
| Rule | Detail |
|---|---|
| Balance | every entry's debits must equal its credits, proven by static analysis of the block at boot. Failing that, the app does not start. |
| Append-only | posted entries are never updated or deleted. A correction is a reversing entry. |
guard |
a precondition evaluated before any line is posted; a false guard raises and posts nothing. It carries its own message, so the failure names the cause rather than a line number (00-build-spec.md D2). |
| Amounts | :money values only. There is no code path that accepts a float. |
audited / immutable_after / idempotent_by
model :Order do
audited # every change writes who/what/when to the audit trail
immutable_after :placed_at # once set, declared fields refuse updates
end
action :capture_payment, idempotent_by: :payment_intent_id do |params|
# a retried call with a seen key returns the first result and posts nothing new
end
flow
Multi-step wizards — onboarding, KYC, checkout. Step state lives server-side, keyed by a resumable token, because app servers are stateless.
flow :Onboarding, policy: :public do # a flow is a surface: it names a verb, or opts out
step :profile do
screen :OnboardingProfile
on_submit { |params, ctx| ctx.merge(profile: params) }
end
step :kyc do
skip_when { |ctx| ctx[:country] != "US" } # D2: `skip_when` skips; `guard` refuses
screen :OnboardingKyc
on_submit { |params, ctx| Kyc.submit(params) and ctx }
end
complete { |ctx| Account.activate!(ctx) }
end
A flow names its own verb and inherits nothing. Onboarding is reachable before an account exists,
so it declares policy: :public; a checkout flow would name a real verb. Inheriting the union of
every verb its steps post to was rejected: it makes a flow's authorization a function of code
elsewhere, unreadable at the declaration site and unpredictable as steps change — which is the second
door policy exists to close. The action behind a step still evaluates its own verb, so
a flow gates entry and each action gates its write. Two gates, one evaluator, neither
implicit.
Phase 6 — API & integration
api
api :V1 do
auth :bearer # or :api_key, :jwt
rate_limit by: :plan
resource :orders, model: :Order do
fields :reference, :status, :total, :placed_at # what the response carries
filterable :status, :placed_at
sortable :placed_at, :total
searchable :reference
writable :reference, :total, :status # what create/update accept
index per_page: 50
show
create
update
destroy
end
end
fields, filterable, sortable, searchable and writable are the same five words on admin_panel, data_table and screen — one vocabulary for "which fields participate", decided in 00-build-spec.md as spelling decision D3. per_page: stays an option because it is a scalar setting, not a field subset.
Pagination, filtering and sorting are generated from the declaration. Serialization follows the model's field types — :money renders as minor units plus a currency, never a float.
webhook
webhook :incoming, :stripe do
verify_signature secret: ENV.fetch("STRIPE_WEBHOOK_SECRET"), scheme: :stripe
acts_as :service, can: %i[Order.capture] # the verbs this endpoint may reach, and no others
on "payment_intent.succeeded" do |event|
Payments.capture(order: Order.find_by_intent!(event[:id]), amount: money(event[:amount], :usd))
end
end
webhook :outgoing, :order_placed do
fires_on :Order, :create
deliver_to { |tenant| tenant.webhook_url }
sign_with :hmac_sha256
retries times: 8, backoff: :exponential
end
An inbound webhook with no verify_signature fails at boot, and so does one with no acts_as
(MAGIK_WEBHOOK_UNSCOPED_ACTOR). A verified signature is not an actor — it authenticates an
origin, not a person — so an incoming webhook names the verbs it may exercise rather than borrowing
policy: :system, which would grant every verb in the application to the one surface a stranger can
call directly. can: is a verb list rather than a role, because there is no actor to carry one; each
verb still resolves through the same policy. Delivery of an outbound webhook runs through the job queue, so it inherits retries and transactional enqueue.
Phase 7 — Auth, billing, admin
auth
auth do
strategy :password
oauth_providers :google, :github
two_factor :totp
session_ttl "14d"
password_rules min_length: 12
end
Rodauth-backed. Generates the account model, migrations, screens and actions for login, signup, reset, verification and 2FA enrolment — all in the same grammar, all overridable by redeclaring the screen.
billing
billing provider: :stripe do
plan :starter, price: money(2900, :usd), interval: :month, trial: "14d" # D1: a :duration
plan :growth, price: money(9900, :usd), interval: :month
plan :usage, metered: :api_calls, unit_price: money(2, :usd)
on_subscription_active { |sub| sub.tenant.update(plan: sub.plan) }
on_payment_failed { |sub| notify :payment_failed, tenant: sub.tenant }
end
admin_panel
Auto CRUD admin for one model, in the same grammar as everything else, and deep enough to be an admin rather than a list: all four view types, in the vocabulary api uses, benchmarked against Avo in 10-saas-coverage.md.
admin_panel :Invoice, policy: %i[Invoice administer] do
list do
fields :number, :customer, :total, :status, :due_on
filterable :status, :select, values: %i[draft issued paid void]
filterable :due_on, :date_range
searchable :number, "customer.name"
saved_view :overdue, label: "Overdue", scope: :overdue
bulk_action :void, action: :void_invoice, args: { reason: :string }, confirm: true
exportable :csv
end
show do
panel t("admin.details") do
field :number, :status, :total, :due_on
end
tab t("admin.lines") do
has_many :line_items, display: :table
end
do
field :created_at, :issued_at
action :issue_invoice, when: ->(i) { i.draft? }
end
end
form do
writable :customer_id, :due_on
writable :status, in: %i[edit] # per-view visibility
fields :total, :issued_at # shown, never written
end
end
App.define :Ledgerline do
admin do
searchable :Invoice, :Customer, :Account # global, across resources
impersonation policy: %i[Account impersonate], banner: true, audited: true
dashboard do
metric :mrr, from: :monthly_recurring_revenue
chart :bar, data: :signups_by_week
end
end
end
policy: is required — an admin panel is by construction the surface with the broadest data
access in the application, and one without a policy is a security hole with a nice table on top. The
field vocabulary is api's, because an admin_panel is a projection of the same model
(00-build-spec.md D3).
| Property | Why it is the right one |
|---|---|
| Admin actions are the app's actions | MAGIK_ADMIN_INLINE_MUTATION forbids a second write path. A Magik admin cannot have a mutation the product does not have — which is the one thing Avo, as a separate admin language, cannot offer |
| Generated from the model, not scaffolded into files | adding a field to the model adds it to the admin. There is no second place to update |
| One theme, one kit, one locale set | an admin that matches the product costs nothing |
Deliberately not copied from Avo: kanban, map view and a media-library browser. Those are product surfaces, not admin primitives.
Phase 8 — i18n, PWA, notifications
locales / translatable
App.define :Shop do
locales :en, :es, :de, default: :en
end
model :Product do
field :name, :string, translatable: true
end
t("key") is the only way user-facing text reaches a screen. A key missing from a shipped locale is a check failure, not a fallback to the key.
pwa
pwa do
name "Shop"
short_name "Shop"
icon "app/assets/icon.png"
display :standalone
theme_color "#101014"
end
Installable only. There is no offline mode and no service-worker cache of app data — declaring one is refused, loudly (05-limits.md).
notification
notification :order_placed do
channel :email, :in_app, :push
subject { |order| t("notify.order_placed.subject", reference: order.reference) }
body { |order| render :OrderPlacedEmail, order: order }
deliver_via :jobs
end
notify :order_placed, to: order.customer, order: order
Email production
notification says what is sent; these declarations say what an email actually is by the time it arrives. They are extensions to the existing construct, not a second one.
notification :invoice_issued do
channel :email do
layout :Transactional # a MAIL layout — not a screen layout
subject { |invoice| t("notify.invoice_issued.subject", number: invoice.number) }
body { |invoice| render :InvoiceIssuedEmail, invoice: invoice }
text :auto # generated plain-text alternative, or a block
inline_css true # default. Mail clients do not do stylesheets
preheader { |invoice| t("notify.invoice_issued.preheader") }
attach { |invoice| pdf :InvoiceDocument, invoice: invoice }
end
unsubscribable_by :customer # signed token, preference row, List-Unsubscribe
deliver_via :jobs
end
magik mail preview :invoice_issued --locale es --out tmp/mail/ # renders, sends nothing
| Piece | Detail |
|---|---|
| Preview | renders to a file, and to a dev-only route. This matters doubly for an agent, which has no inbox — the framework's output is its entire diagnostic surface (07-ai-first.md) |
| Mail layouts | a distinct artifact from screen layouts. Table-safe, inlined, dark-mode-aware, and not the app shell |
| Plain text | generated from the HTML by default. An email with no text/plain part is an email that scores as spam |
| Suppression | framework-owned and consulted by notify before delivery. An app-level suppression table that notify does not read is a suppression list that does nothing |
| Bounces and complaints | inbound provider events over the existing webhook :incoming DSL, writing to the suppression list |
| Unsubscribe | a signed token, a preference record and a List-Unsubscribe header. Required by law for anything non-transactional |
Test helpers gain content assertions, not only delivery:
assert_notified :invoice_issued, to: customer, via: %i[email] do |mail|
expect(mail.subject).to_include(invoice.number)
expect(mail.text).to_be_present
expect(mail.links).to_include(screen(:Invoices, focus: invoice.id))
end
Phase 9 — Testing
test
Compiles to Minitest. expect is the spec's word for the assertion helper; the matcher set is deliberately small, and plain assert_* is always available because this is Minitest (../architecture/04-testing-strategy.md).
test :Orders do
it "captures a payment and posts a balanced entry" do
order = create :Order, status: :placed, total: money(1000, :usd)
perform_action :capture_payment, order_id: order.id, payment_intent_id: "pi_1"
expect(order.reload.status).to_eq(:captured)
expect(Payments.balance(:cash)).to_eq(money(1000, :usd))
assert_enqueued :SettleBatch
end
it "is idempotent under retry" do
order = create :Order, status: :placed, total: money(1000, :usd)
2.times { perform_action :capture_payment, order_id: order.id, payment_intent_id: "pi_1" }
expect(Payments.balance(:cash)).to_eq(money(1000, :usd))
end
end
Helpers: perform_action, render_screen, concurrently(n), travel_to, assert_enqueued, assert_broadcast, assert_notified. Factories are inferred from model field types; tests for required:, validate and ledger balance are generated, not written.
Spec item 12 — Domains
domain
For large apps. One file per domain, domains/<name>/domain.rb. Enforced at boot, not by review.
# domains/billing/domain.rb
domain :Billing do
depends_on :Accounts
exposes :InvoiceSummary, :charge_customer
publishes_events :invoice_paid, :invoice_failed
end
| Rule | Detail |
|---|---|
| Direct access | a domain reaching another domain's model constant fails the boot with MAGIK_DOMAIN_BOUNDARY. |
| Legal paths | the exposed interface, or a subscription to a published event. |
| Undeclared dependency | using Accounts without depends_on :Accounts fails the boot. |
| Cycles | a dependency cycle between domains fails the boot. |
Mechanics and enforcement: ../architecture/02-boundaries.md.