Files
AILang/design/contracts/0002-data-model.md
T
Brummel 76b21c00eb feat(lang): eliminate the Implicit ownership default — totality + the drop-soundness it demasks (#55)
Deletes `ParamMode::Implicit`. `ParamMode` is now `{Own, Borrow}`:
every fn-type slot on every signature carries an explicit `own` or
`borrow`, no defaulted position survives anywhere (model 0008 §2,
spec 0062). The parser rejects a bare fn-type slot; `borrow-return`
and `borrow-over-value` reject at the signature; the corpus is
migrated to minimal-ownership modes (consumed ⇒ own, read-only-heap
⇒ borrow, value ⇒ trivial-own). The documented `Implicit`-ret-mode
leak is fixed: an owned heap return now drops exactly once (live=0,
acceptance criterion 5).

This was the easy half. Removing the default ACTIVATED a family of
drop paths that `Implicit` had silently skipped — the pre-cutover
language was leaking (and in places mis-dropping) here rather than
crashing, because an Implicit scrutinee turned the drop off. Making
the modes explicit (Own) turned those paths on and exposed two
latent-bug clusters, all fixed RED-first as part of this cutover:

Drop-soundness family (four legs):
  A. lit-sub-pattern double-free — the desugar re-matched the same
     owned scrutinee in the lit fall-through; fixed by grouping
     consecutive same-ctor arms into one match (bind fields once),
     in ailang-core desugar.
  B. Cons-husk leak on non-tail arm bodies — the lit-sub-pattern
     desugar rebound the owned scrutinee via `Let $mp = xs`, which
     bumped consume_count and suppressed the existing fn-return
     partial_drop. Fixed by not rebinding a bare-Var scrutinee
     (one husk-freeing mechanism, not two).
  C. polymorphic `drop_<T>` rc_dec'd monomorphised value fields —
     the per-ADT drop fn was emitted once from the polymorphic
     TypeDef, defaulting type-var fields to ptr and rc_dec'ing
     inline Ints (segfault). Fixed with per-monomorph drop
     functions (new ailang-codegen::dropmono): the drop set is
     collected from the lowered MIR, value-type fields are skipped,
     heap fields still freed once; monomorphic-concrete ADTs keep
     their byte-identical un-suffixed drop symbol.
  D. static Str literal passed to an `(own Str)` param — the
     literal lowers to a header-less rodata constant; the callee's
     now-active rc_dec read its length field as a refcount and
     freed a static address (segfault). Fixed with the missing
     fourth StrRep::Static→Heap promotion in lower_to_mir's App arm,
     gated on Own mode (borrow args stay static, no regression).

over-strict-mode lint over-fired: it suggested `(borrow V)` for
value-typed params (which `borrow-over-value` rejects — own is the
only legal mode there) and fired on `(intrinsic)` bodies (whose
consumption the linearity walk cannot observe). Tightened to skip
both; contract 0008 updated to the narrowed firing scope.

Irreversible step — canonical-form hash reset (model 0008 §6,
acceptance criterion 6). Every signature now carries explicit modes,
so the hashable canonical JSON changed for every module. RATIFY:
the corpus-wide hash-pin reset (hash_pin, prelude_module_hash_pin,
mono_hash_stability, eq_ord_e2e, embed_export_hash_stable, the
ct4/iter*/loop_recur schema-extension pins) and the list ir_snapshot
golden were regenerated once, deliberately, as the intended one-time
consequence of removing the mode elision from the canonical form —
not a regression. Each regenerated hash verified deterministic across
two runs.

Also fixes a pre-existing latent failure surfaced by the verification
gate, unrelated to this cutover: the `every_contract_names_a_resolvable_
ratifying_test` resolver (design_index_pin) could not resolve the
" + " dual-link ratifying-test form (`uniqueness.rs + linearity.rs`)
that the #57 audit-close (dfdc65f) introduced — it shipped red on that
commit. Resolver taught the dual-link form, mirroring its sibling.

Verification: cargo test --workspace = 731 passed, 0 failed (twice,
stable); e2e 102 passed, no binary exits non-zero (corpus crash-free);
grep-clean for Implicit/fn_implicit/mode_eq across crates; every drop
fix confirmed via emitted IR + AILANG_RC_STATS balance on the head==K,
head!=K, and Nil paths. Three BLOCKEDs en route (the unsound first
husk-dec attempt, the over-strict derivation premise, the leg-B fix
direction) were each treated as a real design/spec gap and rediagnosed,
not patched over.

Supersedes #54 (return-position-only leak patch). Precondition #57
(linearity hardening) was already met. Spec docs/specs/0062, plan
docs/plans/0121.

closes #55
2026-06-02 00:03:46 +02:00

12 KiB

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

{
  "schema": "ailang/v0",
  "name": "<id>",
  "kernel": true,                                  // optional; omitted when false (hash-stable when omitted). Kernel-tier modules are auto-imported by every consumer. See prep.3 of the kernel-extension-mechanics milestone.
  "imports": [{ "module": "<id>", "as": "<id>" }],
  "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 <module>.<Class> / <module>.<TypeName>) follow the scoping rule in memory model; the class/instance schema narrative — defaults, superclasses, diagnostics — lives in typeclasses. Exported fn defs interact with embedding ABI.

// fn (the unit that gets a content hash)
{ "kind": "fn",
  "name": "<id>",
  "type": Type,            // typically Type::Fn, optionally wrapped in Forall
  "params": ["<id>"...],   // names bound in body, in type.params order
  "body": Term,
  "doc": "<optional string>",
  "export": "<optional C symbol>", // 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": "<id>",
  "type": Type,
  "value": Term,
  "doc": "<optional string>"
}

// type (algebraic data type; parameterised)
{ "kind": "type",
  "name": "<id>",
  "vars": ["<id>"...],     // type parameters; omitted when empty (hash-stable when omitted)
  "ctors": [
    { "name": "<id>", "fields": [Type...] }  // nullary ctor: fields = []
    ...
  ],
  "doc": "<optional string>",
  "drop-iterative": true,  // opt-in; omitted when false (hash-stable when omitted)
  "param-in": { "<var>": ["<TypeName>", ...] }   // closed-set restriction per type variable; omitted when empty (hash-stable when omitted). See prep.3 of the kernel-extension-mechanics milestone.
}

// class (typeclass declaration; narrative in contracts/0013-typeclasses.md)
{ "kind": "class",
  "name": "<id>",          // class name (e.g. "Show")
  "param": "<id>",          // single class parameter, kind *
  "superclass": null,       // or { "class": "<id>", "type": "<param>" } — "class": canonical form (bare for same-module, "<module>.<Class>" for cross-module)
  "methods": [
    { "name": "<id>",
      "type": Type,         // FnSig over the class param
      "default": Term       // optional fallback body; null = abstract-required
    }
    ...
  ],
  "doc": "<optional string>"
}

// instance (typeclass instance; narrative in contracts/0013-typeclasses.md)
{ "kind": "instance",
  "class": "<id>",          // class being instantiated; canonical form (bare for same-module, "<module>.<Class>" for cross-module)
  "type": Type,             // concrete type expression (never the class param)
  "methods": [
    { "name": "<id>", "body": Term }
    ...
  ],
  "doc": "<optional string>"
}

Suppress (entry in FnDef.suppress):

{ "code": "<diagnostic-code>",   // e.g. "over-strict-mode"
  "because": "<author reason>"   // must be non-empty;
                                 // empty/whitespace fires `empty-suppress-reason` (Error)
}

Term (expression)

{ "t": "lit", "lit": Literal }
{ "t": "var", "name": "<id>" }

// 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": "<id>", "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": "<id>", "type": Type, "params": ["<id>"...],
  "body": Term, "in": Term }

{ "t": "if",   "cond": Term, "then": Term, "else": Term }

// Effect-op invocation. `op` is "<eff>/<op>" (e.g. "io/print_str").
// `tail` triggers musttail (omitted when false).
{ "t": "do",   "op": "<eff>/<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": "<id>", "ctor": "<id>", "args": [Term...] }

{ "t": "match", "scrutinee": Term, "arms": [Arm...] }

// Anonymous fn value; free vars captured from enclosing scope.
{ "t": "lam",
  "params": ["<id>"...],
  "param-types": [Type...],
  "ret-type": Type,
  "effects": ["<id>"...],
  "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": "<id>", "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, ... ] }

// new: functional construction. Resolves `type` via type-scoped
// lookup to its home module, then calls the home module's `new`
// def with the supplied args. Each arg is a `NewArg` (see below).
// Type-args (kind = "type") instantiate the `new` def's outer
// `Forall` vars in declaration order; Value-args (kind = "value")
// are checked against the (substituted) param types. Strictly
// additive (no `skip_serializing_if`; pre-existing fixtures hash
// bit-identically — none carry the tag). See prep.2 of the
// kernel-extension-mechanics milestone.
{ "t": "new",
  "type": "<TypeName>",
  "args": [ NewArg, ... ] }

// intrinsic: the body of a compiler-supplied definition. Legal only as
// a FnDef/Lam body, only in a (kernel)-tier module or the prelude
// (typecheck: intrinsic-outside-kernel-tier). Never reduces to a value;
// codegen consumes it via the intercept registry. A def is intrinsic
// iff its body is this term. Strictly additive (no skip_serializing_if;
// pre-existing fixtures hash bit-identically — none carry the tag).
{ "t": "intrinsic" }

NewArg (one positional arg to a (new T args...) call):

// Type-positional arg: instantiates one of `new`'s outer Forall
// vars. The inner `value` carries a full `Type` JSON object.
{ "kind": "type",  "value": Type }

// Value-positional arg: a `Term` checked against the corresponding
// substituted param type of `new`. The inner `value` carries a
// full `Term` JSON object.
{ "kind": "value", "value": 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. A lam term constructs an anonymous function value; free variables of its body are captured from the enclosing scope. A lam body may be { "t": "intrinsic" }, in which case the lambda is compiler-supplied (the synthesised-instance-method form).

A fn def's body may be { "t": "intrinsic" }, in which case the def is compiler-supplied: the typechecker validates only its signature and codegen routes it through the intercept registry. Such a body is legal only in a (kernel)-tier module or the prelude.

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:

{ "kind": "int",  "value": <i64> }
{ "kind": "bool", "value": <bool> }
{ "kind": "str",  "value": "<utf-8>" }
{ "kind": "unit" }
{ "kind": "float", "bits": "<16-lowercase-hex>" }

Pattern (the pat field of an Arm; discriminator p):

{ "p": "wild" }                                   // _
{ "p": "var",  "name": "<id>" }                   // x — binds the value
{ "p": "lit",  "lit": Literal }
{ "p": "ctor", "ctor": "<id>", "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 <module>.<TypeName> for cross-module) lives in memory model; Type::Fn's parameter-mode metadata is defined and gated there as well.

// Type-constructor application. `args` omitted when empty
// (hash-stable when omitted, for non-parameterised cases like Int, Bool, ...).
{ "k": "con", "name": "<id>", "args": [Type...] }   // "name": canonical form (bare for same-module / primitives, "<module>.<TypeName>" 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` and `retMode` are always present (one mode per slot).
// Full mode contract lives in contracts/0008-memory-model.md.
{ "k": "fn",
  "params":     [Type...],
  "paramModes": [ParamMode...],
  "ret":        Type,
  "retMode":    ParamMode,
  "effects":    ["<id>"...] }

{ "k": "var", "name": "<id>" }

// Top-level polymorphism only. `constraints` carries class
// constraints (narrative in contracts/0013-typeclasses.md); omitted when
// empty (hash-stable when omitted).
{ "k": "forall",
  "vars": ["<id>"...],
  "constraints": [{ "class": "<id>", "type": "<id>" }, ...],   // "class": canonical form (bare for same-module, "<module>.<Class>" for cross-module)
  "body": Type }

ParamMode (full contract in memory model):

"own"        — (own T) — caller transfers ownership; callee consumes.
"borrow"     — (borrow T) — caller retains ownership; callee may not consume.

Every fn-type slot carries own or borrow; ownership has no default — there is no bare/unannotated mode. The full mode contract (codegen consequences, the over-strict-mode lint, the Suppress mechanism) lives in memory model; the four language-design preconditions that make RC sound live in language constraints.

Ratified by: crates/ailang-core/tests/design_schema_drift.rs.