# Data model ## Data model The on-disk JSON-AST is what the toolchain hashes, typechecks, and lowers. **This section is the canonical schema.** The Rust types in `crates/ailang-core/src/ast.rs` are the in-memory projection of it; when the two disagree, this section wins, and the drift test `crates/ailang-core/tests/design_schema_drift.rs` fires. Every additive field is declared with `skip_serializing_if` so pre-existing fixtures keep bit-identical canonical-JSON hashes — that gating contract is what makes growing the schema cheap. ### Module ```jsonc { "schema": "ailang/v0", "name": "", "imports": [{ "module": "", "as": "" }], "defs": [Def...] } ``` ### Def `kind ∈ { "fn", "const", "type", "class", "instance" }`. All five are real surface forms. Class and type cross-module references (canonical-form rule, qualified `.` / `.`) follow the scoping rule in [memory model](0008-memory-model.md); the `class`/`instance` schema narrative — defaults, superclasses, diagnostics — lives in [typeclasses](0013-typeclasses.md). Exported `fn` defs interact with [embedding ABI](0003-embedding-abi.md). ```jsonc // fn (the unit that gets a content hash) { "kind": "fn", "name": "", "type": Type, // typically Type::Fn, optionally wrapped in Forall "params": [""...], // names bound in body, in type.params order "body": Term, "doc": "", "export": "", // omitted when absent (hash-stable when omitted); embedding-ABI surface — see prose below "suppress": [Suppress...] // omitted when empty } // const (top-level value; codegen emits as a global; body must be pure) { "kind": "const", "name": "", "type": Type, "value": Term, "doc": "" } // type (algebraic data type; parameterised) { "kind": "type", "name": "", "vars": [""...], // type parameters; omitted when empty (hash-stable when omitted) "ctors": [ { "name": "", "fields": [Type...] } // nullary ctor: fields = [] ... ], "doc": "", "drop-iterative": true // opt-in; omitted when false (hash-stable when omitted) } // class (typeclass declaration; narrative in contracts/0013-typeclasses.md) { "kind": "class", "name": "", // class name (e.g. "Show") "param": "", // single class parameter, kind * "superclass": null, // or { "class": "", "type": "" } — "class": canonical form (bare for same-module, "." for cross-module) "methods": [ { "name": "", "type": Type, // FnSig over the class param "default": Term // optional fallback body; null = abstract-required } ... ], "doc": "" } // instance (typeclass instance; narrative in contracts/0013-typeclasses.md) { "kind": "instance", "class": "", // class being instantiated; canonical form (bare for same-module, "." for cross-module) "type": Type, // concrete type expression (never the class param) "methods": [ { "name": "", "body": Term } ... ], "doc": "" } ``` **`Suppress`** (entry in `FnDef.suppress`): ```jsonc { "code": "", // e.g. "over-strict-mode" "because": "" // must be non-empty; // empty/whitespace fires `empty-suppress-reason` (Error) } ``` ### Term (expression) ```jsonc { "t": "lit", "lit": Literal } { "t": "var", "name": "" } // fn application; tail flag triggers musttail under codegen. // `tail` is omitted when false (hash-stable when omitted). // `args` may be empty: a nullary call is the surface form // `(app f)` (resolution of Gitea #12). Read-tolerant: a JSON // document omitting the `args` key deserialises to `[]`. { "t": "app", "fn": Term, "args": [Term...], "tail": false } { "t": "let", "name": "", "value": Term, "body": Term } // Local recursive let. Always fn-shaped. The desugar pass // lifts most `letrec` to a synthetic top-level fn; `lift_letrecs` // finishes the job after typecheck for the residue that captures // let-bound names. Post-codegen, no `letrec` survives. { "t": "letrec", "name": "", "type": Type, "params": [""...], "body": Term, "in": Term } { "t": "if", "cond": Term, "then": Term, "else": Term } // Effect-op invocation. `op` is "/" (e.g. "io/print_str"). // `tail` triggers musttail (omitted when false). { "t": "do", "op": "/", "args": [Term...], "tail": false } // Ctor application. `args` is always emitted on write (including // as `"args": []` for niladic ctors); reads tolerate the key being // absent and treat it as `[]`. This mirrors the read/write // asymmetry on `Term::App.args` (see above). { "t": "ctor", "type": "", "ctor": "", "args": [Term...] } { "t": "match", "scrutinee": Term, "arms": [Arm...] } // Anonymous fn value; free vars captured from enclosing scope. { "t": "lam", "params": [""...], "param-types": [Type...], "ret-type": Type, "effects": [""...], "body": Term } // Sequencing. Semantically `let _ = lhs in rhs`; lhs must be Unit. { "t": "seq", "lhs": Term, "rhs": Term } // Explicit RC clone. Codegen lowers as // `call void @ailang_rc_inc(ptr %v)` before returning %v under `--alloc=rc`. { "t": "clone", "value": Term } // Explicit reuse-as hint. `body` must be allocating // (typically `ctor` or `lam`); `source` must be a bare `var`. Codegen // lowers as in-place rewrite under `--alloc=rc`. { "t": "reuse-as", "source": Term, "body": Term } // loop: strict iteration block. `binders` declares // one or more loop parameters (name, type, init), evaluated in // order on loop entry; `body` is in scope of all binders. The // loop's value is `body`'s value on the iteration that exits via a // non-`recur` branch. Strictly additive (no `skip_serializing_if`; // pre-existing fixtures hash bit-identically — none carry the tag). // No totality claim — an infinite loop is legal. See // `docs/specs/0034-loop-recur.md`. { "t": "loop", "binders": [ { "name": "", "type": Type, "init": Term }, ... ], "body": Term } // recur: re-enter the lexically innermost enclosing // `loop`, rebinding its binders positionally to `args`. Transfers // control (no fall-through); valid only in tail position of its // enclosing loop (enforced at typecheck, `recur-not-in-tail-position`). { "t": "recur", "args": [ Term, ... ] } ``` In the MVP, `do` is only a direct call to a built-in effect op (no handler); the effect system is described in [effects](../models/0002-effects.md). A `lam` term constructs an anonymous function value; free variables of its body are captured from the enclosing scope. Loop binders are alloca-resident: typecheck binds them in the ordinary local scope plus a positional `loop_stack`, and codegen lowers them as entry-block allocas. Capturing a `loop` binder into a lambda body is rejected at typecheck via `CheckError::LoopBinderCapturedByLambda`. See `docs/specs/0034-loop-recur.md`. **`Literal`**: ```jsonc { "kind": "int", "value": } { "kind": "bool", "value": } { "kind": "str", "value": "" } { "kind": "unit" } { "kind": "float", "bits": "<16-lowercase-hex>" } ``` **`Pattern`** (the `pat` field of an `Arm`; discriminator `p`): ```jsonc { "p": "wild" } // _ { "p": "var", "name": "" } // x — binds the value { "p": "lit", "lit": Literal } { "p": "ctor", "ctor": "", "fields": [Pattern...] } // fields omitted when empty ``` Patterns are linear: each pattern variable may appear at most once. ### Type The `Type::Con.name` canonical-form rule (bare for same-module / primitives, qualified `.` for cross-module) lives in [memory model](0008-memory-model.md); `Type::Fn`'s parameter-mode metadata is defined and gated there as well. ```jsonc // Type-constructor application. `args` omitted when empty // (hash-stable when omitted, for non-parameterised cases like Int, Bool, ...). { "k": "con", "name": "", "args": [Type...] } // "name": canonical form (bare for same-module / primitives, "." for cross-module) // Function type. paramModes/retMode are metadata on Type::Fn — // they are NOT separate Type variants, so every existing match-arm // in the typechecker (unify, occurs, apply) keeps working. // `paramModes` omitted when every entry is "implicit"; `retMode` // omitted when "implicit" (hash-stable when omitted). Full mode // contract lives in contracts/0008-memory-model.md. { "k": "fn", "params": [Type...], "paramModes": [ParamMode...], "ret": Type, "retMode": ParamMode, "effects": [""...] } { "k": "var", "name": "" } // Top-level polymorphism only. `constraints` carries class // constraints (narrative in contracts/0013-typeclasses.md); omitted when // empty (hash-stable when omitted). { "k": "forall", "vars": [""...], "constraints": [{ "class": "", "type": "" }, ...], // "class": canonical form (bare for same-module, "." for cross-module) "body": Type } ``` **`ParamMode`** (full contract in [memory model](0008-memory-model.md)): ``` "implicit" — unannotated / back-compat. Treated as `own` by the typechecker. "own" — (own T) — caller transfers ownership; callee consumes. "borrow" — (borrow T) — caller retains ownership; callee may not consume. ``` `implicit ≡ own` semantically; the distinction exists so existing unannotated fixtures continue to serialize without the mode wrapper and keep their canonical-JSON hash. The full mode contract (codegen consequences, the over-strict-mode lint, the `Suppress` mechanism) lives in [memory model](0008-memory-model.md); the four language-design preconditions that make RC sound live in [language constraints](0015-language-constraints.md). Ratified by: `crates/ailang-core/tests/design_schema_drift.rs`.