Macro atom
The macro atom is the fifth substrate atom (meta-calculus, constructs, rule, trait, macro) and the extensibility layer the ontology-neutral doctrine depends on: it is how a vocabulary library grows the surface without a compiler change. Its design is settled by RFD 0037; this chapter is the surface specification.
The declarative expansion engine — pub macro declarations expand at EXPAND (hygiene, repetition, attribute/decl-position invocation, cross-module imports), and their output re-elaborates through the ordinary path to events. The procedural layer (#[procmacro] pub fn, Procedural macros) provides concat_idents, quote/${} antiquotation, decl-reflection, and a total non-recursive body — the mechanism that re-homes #[irreflexive]/#[asymmetric] from compiler builtins to library procedural macros. #[functional] is a builtin-backed macro (Migration: re-homing the hard-coded surface).
The shape of the design. A macro expands to surface syntax, which is re-parsed and re-elaborated through the one existing path to events — never to events directly. Expansion is a phase between parse and resolve, run to a fixed point; the resolver, type checker, Name resolution ontology-neutrality gate, and decidability-tier classifier all run on the post-expansion program, so a macro is invisible to none of them. The declarative layer (pub macro, pattern→template) is the foundation; the procedural layer (#[procmacro], computation over reflected syntax) is layered on top (Procedural macros).
Declarative macros
macro-decl ::= attribute* 'pub'? 'macro' Ident '{' macro-rule (';' macro-rule)* ';'? '}'
macro-rule ::= '(' macro-pattern ')' '=>' '{' template '}'
macro-pattern ::= /* token tree with $name:SPEC metavariables and $(...)* / $(...)+ / $(...)? repetitions */
A declarative macro is a sequence of (pattern) => { template } rules. A pattern matches a token tree; metavariables are tagged with a fragment specifier that fixes the syntactic category matched and the binding space the bound name lives in. The template is surface syntax, with metavariable substitution and $( … )*/+/? repetitions (optional separator: $( … ),*).
pub macro vec {
() => { Vec::empty() };
($head:expr $(, $tail:expr)*) => { /* … */ };
}
let xs = vec!(1, 2, 3);
Fragment specifiers. The syntactic categories $x:expr, $t:ty, $i:ident, pat, stmt, block, item, literal, path, tt; plus four Argon-specific specifiers, each a binding-space-targeted parse — $c:concept parses a concept-introducing declaration and binds in the concept space, $r:rel a relation declaration (rel space), $m:metatype a metatype introducer, and $u:rule a rule-body atom (carrying its own rule-variable sub-bindings). The set is closed; a standpoint specifier and a user-extensible registry are out of scope (Out of scope). Because a concept/metatype specifier only binds — the Name resolution gate runs later, at resolution (Hygiene) — and no specifier carries a tier, ontology-neutrality and tier-honesty hold by construction.
The expansion model
Expansion is a distinct compilation phase:
lex → parse → expand* → resolve → check → instantiate (lower to events) → tier-classify → discharge
- Expand to surface, re-elaborate. A macro produces surface syntax that is spliced into the module and re-parsed; it then flows through the same resolve → check → lower path as hand-written source. The content-addressed
AxiomKindevent wire is produced only by the unchanged lowering, so a macro inherits the substrate’s identity and drift guarantees for free. A macro never emits events directly. - Fixed point. A macro whose output contains further invocations re-expands until none remain, bounded by a fuel cap that errors on exhaustion.
- Emit into the invocation module. A macro expands at its use site and its output lands in the invoking module, where rules it emits compose with the user’s by the same-module same-head union (
derive). It cannot contribute to another module’s heads. - Classifier-last gives tier-honesty. The decidability-tier classifier runs after expansion on the lowered events, so the program lands on its true tier — a macro that emits a recursive rule is classified exactly as if the rule were hand-written, and cannot smuggle the program across a tier boundary.
- Determinism. The declarative layer is strongly normalizing by construction, has no I/O, and emits collections in a canonical order. Rule variables are alpha-canonicalized at lowering (Hygiene), so a macro’s choice of fresh variable names never affects the
.oxbincontent hash. Expansion is therefore a deterministic function of its input, and reproducible builds need no author discipline.
Hygiene
Macro-introduced binders neither capture nor are captured by names at the use site, across all of Argon’s binder namespaces (rule variables, concept/type, rel/metarel, metatype/metaxis + axis values, individuals, trait members). The model is scope-sets with binding spaces (Flatt 2016 + Racket binding spaces): each namespace is an interned scope inside one scope-set, resolved by the unchanged maximal-subset rule. Hygiene is white-box — the quotation applies a fresh macro scope to the identifiers a macro introduces.
The Name resolution ontology-neutrality gate composes as a commuting pass: scope-set resolution (syntactic) produces a candidate binding, then the gate (semantic) checks that a macro-introduced concept resolves to a visible pub metatype — it may reject, but never re-points a reference. Macro-introduced vocabulary is not exempt from the gate.
Two regimes follow from what reaches event identity:
- Rule variables are clause-local and alpha-canonicalized at lowering, so hygiene need only guarantee non-capture; their names are irrelevant to identity.
- Introduced vocabulary (a concept/relation a macro declares) has a semantic, referenceable name, so hygiene gives it a content-derived, stable identity.
Macros are hygienic-only: there is no unhygienic!. A $crate-style self-reference plus syntax-parameter keywords cover the real needs; any future raw escape must name the binding space it breaks into.
Imports and the shared #[…] namespace
A pub macro foo { … } declaration occupies a single symbol in the module namespace. use mod::foo; brings it into scope; foo!(args) invokes it at expression position and #[foo(args)] at declaration position. The ! and #[…] sigils are invocation markers, not separate-namespace tags — there is no distinct macro namespace, and no #[macro_use] form; macro imports follow ordinary item-import rules (Imports).
User macros and compiler-known directives share the #[…] namespace. Resolution is by uniqueness at registration: a name resolves to exactly one implementation, and a user macro colliding with a built-in directive is an OE0705-class error, never a silent shadow (matching the loud-refusal discipline of the directive registry, Directive surface and the MLT decorator path). Identity is keyed to a stable handle, not the spelling, so re-homing a directive (Migration: re-homing the hard-coded surface) does not change how a #[name] resolves.
Procedural macros
A procedural macro computes its expansion by running code, rather than rewriting a pattern into a template. It is not a fn over a TokenStream: the procedural layer is a total, structurally-recursive meta-language over reflected syntax, not general-purpose computation. Totality is what the substrate’s own doctrine requires — it is strongly normalizing and deterministic by construction, and (being a total function over an inductive syntax type) mechanizable in Lean (Lean correspondence).
A procedural macro is a #[procmacro] pub fn in the one macro namespace:
#[procmacro]
pub fn irreflexive(item: Decl) -> Syntax {
let head = concat_idents("__", item.name, "_irreflexive");
quote {
$item;
pub check $head(x: $item.params[0].ty) :- $item.name(x, x)
=> Diagnostic { severity: Severity::Error, code: "Argon::OE1359",
message: "relation `${item.name}` is #[irreflexive] but relates an element to itself" };
}
}
The body is a total, non-recursive fragment: let bindings over the construction builtins and reflection projections, then a trailing quote. The surface is:
quote { … }— aQUOTE_EXPR; its body is re-parsed through the ordinary elaboration path (never emitted as events directly). Splices:$name(alet-bound value),$item(the whole reflected declaration, re-emitted verbatim),$item.name(the relation’s name),$item.params[i].ty(a parameter’s type).${expr}antiquotation — an in-string splice (e.g. inside a diagnosticmessage:), resolved by the macro renderer at EXPAND. It is render-time, not runtime string concatenation (the runtime has noText + Text— that is a separate arc).concat_idents(a, b, …)(paste) — builds a raw definition-site identifier from pieces; the single capability the splice-only declarative layer lacks. The result is not freshened — the macro intends it as the stable public head (__{rel}_{prop}); splices are substitution barriers carrying their own use-site identity.
Bodies are non-recursive (trivially total). #[procmacro] resolves as a macro (DefKind::Macro), runs at EXPAND by a dedicated evaluator, and its #[procmacro] directive executes at fn-decl position. #[irreflexive] and #[asymmetric] (Migration: re-homing the hard-coded surface) are genuine procedural macros written exactly this way.
Full structural recursion over reflected Syntax — a totality checker, a derive-class macro inspecting a concept’s fields, #[functional]’s rel-cardinality-cap arm (which re-emits a rel with a modified cardinality bracket), and the classify ∘ expand tier-honesty theorem — is out of scope (Out of scope).
Directive surface and the MLT decorator path
Every #[…] attribute must resolve to a registered compiler directive or an in-scope pub macro (e.g. #[transitive], re-homed to std::rel, Migration: re-homing the hard-coded surface / RFD 0037 D8 — brought in by use). The directive registry (oxc-instantiate’s DIRECTIVE_REGISTRY) is the single declarative table of every directive the toolchain reads or reserves, with its argument shape, valid positions, and status. There are no silently-ignored attributes: an attribute that is neither a registered directive nor an in-scope macro refuses with OE0705 UnknownDirective (nearest-known suggestion), documented-but-unbuilt directives with OE0706 DirectiveReservedUnimplemented, misplaced directives with OE0707 DirectiveInvalidPosition, argument forms on bare-form directives with OE0709 AttributeArgsNotYetImplemented, and conflicting families with OE0714 DirectiveConflict.
The relation-property directives are library macros, split by mechanism (Migration: re-homing the hard-coded surface):
- Relation-property directives (RFD 0031) —
#[transitive]is a declarativepub macroinstd::rel(closure deriveR(x, z) :- R(x, y), R(y, z)into the relation’s own head);#[irreflexive]/#[asymmetric]are genuine procedural macros (#[procmacro], RFD 0040) that re-emit the decorated declaration and paste a__{rel}_{prop}check head viaconcat_idents(Procedural macros);#[functional]is a builtin-backed macro (#[builtin] pub macro, the#[rustc_builtin_macro]analogue,OE1362if unimported), since its rel-cardinality-cap[0..1]arm requires structural re-emission. The declarative and procedural forms format Argon source and re-parse it through the ordinary derive/check pipeline;#[functional]’s privileged path synthesizes the guard (and the cap) directly. - MLT decorators —
#[categorizes(T)],#[partitions(T)],#[subordinate_to(M)],#[power_type_of(T)], and#[order(N)]are no longer compiler directives at all. They are genuine library#[procmacro]s in the unprivilegedmltpackage (notstd): each re-emits the decorated declaration and emits aMetaPropertyon the metarel (or, for#[order], the metaxis) it shares its name with — reading the argument’s name (or, for#[order], an integer), not its structure. Nothing is ambient: a consumer listsmltinox.tomland imports the vocabulary (use mlt::*;/use mlt::{categorizes};), and an unimported decorator is an unknown attribute macro, refused by the no-silent-attribute gate (OE0705). The emittedMetaPropertyis re-checked at the emission boundary and again at load —#[order]’s value is validated against themlt::ordermetaxis’s declaredNat where { _ <= 16 }domain by the self-validating.oxbinload re-check (OE0623), so the bound holds without any compiler privilege. The reasoner-side enforcement of the MLT closure rules over the emitted metaproperties is authored as themltpackage’s own rules/checks over the neutral substrate (Phase 2).
Directives as library macros
The hard-coded directive surface is library macros, with the user-facing syntax byte-identical to a privileged-path synthesis (RFD 0009 RP-003 GAP-3). The mechanism is decided by the directive’s emit target:
- Relation-property emits ordinary rules (which have surface), so it lives in
std::rel, split by what each member needs:#[transitive]is a declarativepub macro: the macro template produces lowered events byte-identical to a direct synthesis (verified differentially).#[irreflexive]/#[asymmetric]are genuine procedural macros (#[procmacro] pub fn … -> Syntax, RFD 0040): each re-emits the decorated declaration ($item) and pastes apub checkwhose head isconcat_idents("__", item.name, "_{prop}")— the unique ident the splice-only declarative layer cannot build. Their import gate isOE0705(a bare unimported#[irreflexive]is an unknown attribute macro, liketransitive).#[functional]is a#[builtin]macro (importable fromstd::rel,OE1362if not), with privileged synthesis as the implementation. It is one attribute covering both ametarel-check (paste-able) and arel-cardinality-cap[0..1]on the target endpoint — and the cap arm re-emits arelwith a modified cardinality bracket, i.e. structural re-emission. An attribute name is builtin or procmacro, not split across both halves, sofunctionalis entirely builtin.
- MLT emits
MetaPropertyevents. Its surface is the unprivilegedmltpackage (RFD 0043): every decorator — the relational#[categorizes]/#[partitions]/#[subordinate_to]/#[power_type_of]and the metaxis-valued#[order(N)]— is a genuine#[procmacro]emitting a re-checkedMetaPropertythrough the emission boundary (Procedural macros). There is no compiler builtin, nostd::mlt, and no reserved MLT diagnostic code; the expander is the package’s own procmacro, not a privileged compiler path. MLT is one higher-order type theory among several (potency, ML2, powertype), so the substrate stays neutral about which one a model commits to. std::temporal/std::lifecyclebecome declarative library macros; whether each operator ships native or library is a scoping choice once the engine exists.
// std/temporal.ar — a declarative desugaring to rule-body atoms (no computation)
pub macro since {
( $lhs:rule since [ $lo:literal , $hi:literal ] $rhs:rule ) => {
@meta_window($t, $lo, $hi), $rhs at $t, holds_throughout($lhs, $t, now)
};
}
Lean correspondence
The substrate at spec/lean/Argon/Substrate/Macro.lean mechanizes the shape of macro declarations — MacroAtom = declarative MacroDecl | procedural ProcMacroDecl as @[language_interface] carriers policed by the drift gate. Per RFD 0037 D7, the substrate boundary is held at the typed AST: macro expansion is untrusted and Rust-side, and assurance comes from re-checking its output — the tier classifier runs last, the content hash is re-derivable, and the drift gate covers the MacroAtom shape. Mechanize the re-checker, not the producer.
The tier-honesty theorem — that classify ∘ expand lands a program on the same decidability class as its expansion — is statable only once expand is a total Lean object over an inductive Syntax type (RFD 0040 D7). The scope-set hygiene algebra and a full expansion-preservation theorem are out of scope.
The MLT-relational kinds — instanceOf, specializes, categorizes, partitions, subordinates — live in spec/lean/Argon/MetaCalculus/MLTKinds.lean, with the round-trip theorem classify_declOfKind; the elaborator emits canonical metarel_decl events via declOfKind.