# Canonical Type Names — Module-Scoped Qualification — Design Spec **Date:** 2026-05-10 (revised iteratively through 2026-05-11; see git log of this file for the revision sequence) **Status:** Draft — awaiting user spec review **Authors:** Brummel (orchestrator) + Claude ## Goal Establish a single, unambiguous rule for `Type::Con` names in `.ail.json`: - The file's top-level `"name"` field is THE module context for everything inside. - A bare `Type::Con.name` always means *local to this module*. - A cross-module reference MUST be qualified `"."` — bare cross-module refs are a schema violation. - Primitives (`Int`, `Bool`, `Str`, `Unit`, `Float`) stay bare and have no module qualifier. Under this rule, every `Type::Con` is unambiguous: bare = local, qualified = explicitly cross-module. The bug-class fixed by this milestone (cross-module references silently mismatching when one side stays bare and another resolves through an imports-fallback) becomes structurally impossible — there is no bare cross-module form in canonical JSON. The user's directive on 2026-05-11: *"You can omit local-name qualification in `.ailx` files because it follows directly from the module name — that's source code. But in the AST, i.e. in the data structure, i.e. in the `.ail.json` files, everything must be fully qualified. Every AST object must stand alone, without a module context."* And after seeing `bench_mono_dispatch.ail.json`'s existing shape: *"the module qualifier is at the top of the file already (`"name": "..."`); references within the file can stay bare when intra-module; cross-module refs need explicit qualifier."* The module IS the unit of context. A single `Type::Con` in isolation is interpretable iff you also have its owning module's `"name"`. The .ail.json file already pairs these (one Module per file, with `"name"` at the top and Defs nested inside). This spec formalises that pairing as the canonical-form rule. ## Architecture The `.ail.json` schema gains a load-time validation rule: For every `Type::Con { name, args }` encountered while loading a module `M`: 1. If `name.contains('.')`: must be `.` where `` is a known module (the workspace's module map) AND `` is a type def in ``. Else `WorkspaceLoadError::BadCrossModuleTypeRef`. 2. Else if `name` is a primitive (`Int`/`Bool`/`Str`/`Unit`/`Float`): accepted, no further check. 3. Else (bare non-primitive): `name` must be a type def in `M`'s own `defs`. Else `WorkspaceLoadError::BareCrossModuleTypeRef { module: M, name, candidates }` — candidates lists the qualified forms found in `M`'s imports so the error message can suggest the right rewrite. The same rule applies to `Term::Ctor.type_name` (which is conceptually a Type::Con name embedded in a Term). `Type::Var` is unchanged — different AST variant via the `"k"` tag, never a qualifier candidate. The Form-B prose surface (`.ailx`) continues to allow bare-local names everywhere. The prose printer/parser are the bridge: when writing prose from a canonical `.ail.json`, the printer trims a qualifier if its owner equals the current file's module (`prelude.Ordering` in prelude's own file → printed as `Ordering`). When parsing prose, the parser keeps bare names bare (they're local by construction since the prose was written in the context of a single module). Cross-module refs in prose carry their qualifier verbatim through both directions. There is **no internal canonicalisation pass** in `load_workspace`. The AST is already canonical post-parse because bare = local is an unambiguous rule. The validator catches stale fixtures that have bare cross-module refs (legacy state from when imports-fallback papered over the issue). ## Components The milestone decomposes into four iterations. | Iter | Scope | |------|-------| | **ct.1** | `.ail.json` schema validator at `load_workspace` (the rule above) + one-time migration. The migration is targeted: a workspace-walker tool (small Rust binary or test-driven script under `crates/ail/src/bin/`) reads every `.ail.json` under `examples/`, finds bare cross-module Type::Con names (i.e. bare names that don't resolve to a local type def in the same file), looks up the qualified form via the import graph (using the same first-match-in-imports-order disambiguation rule the existing imports-fallback uses, so the rewrite never silently changes which type a fixture resolves to), rewrites in place. Files with only intra-module refs are unchanged. Single bulk commit for the rewritten fixtures. The validator also rejects *qualified* class-name fields (`InstanceDef.class`, `SuperclassRef.class`, `Constraint.class`) — those stay bare per the Out-of-scope note. | | **ct.2** | Remove obsolete typechecker mechanisms that papered over bare cross-module refs: `Pattern::Ctor`'s imports-fallback (iter 15a, ~`crates/ailang-check/src/lib.rs:2486-2521`) and `Term::Ctor`'s synth imports-fallback (iter 23.1.3, ~lib.rs:1979-2025). Both become unreachable because bare-non-primitive type-name reaches them only when local (direct lookup succeeds) — never as a stale cross-module ref. **`qualify_local_types` (iter 15a, ~lib.rs:1848) STAYS** — it correctly handles the cross-boundary translation when a cross-module fn signature is pulled into the current module's typecheck context. Under the new rule it becomes more prominent (called uniformly at every cross-module fn-lookup boundary, where today it runs only at the Term::Var prefix-qualified path); this iter audits its call sites for consistency. | | **ct.3** | Remove obsolete codegen + mono mechanisms: `lookup_ctor_by_type`'s imports-fallback (iter 23.1.4) and `apply_per_module_ctor_index_overlay` in mono (iter 23.2 fix `84dcc46`). The codegen `lower_workspace_inner` implicit-prelude import_map entry (iter 23.2.4 fix `be882c4`) STAYS — fn-name resolution for mono symbols, not Type::Con names. | | **ct.4** | DESIGN.md amendments + JOURNAL migration entry + re-baseline of hash-pinned regression tests that touched cross-module-ref fixtures + IR snapshot refresh (where affected). Form-B prose round-trip test extended to cover the qualifier-trim-on-print behaviour for intra-module refs. | After ct.4, iter 23.3 Task 4+5 resume (recreate `examples/compare_primitives_smoke.ail.json` per the iter-23.3 plan; expected to pass without further compiler changes). ## Data flow ### Example 1: intra-module file (no changes from today) `examples/bench_mono_dispatch.ail.json` has `"name": "bench_mono_dispatch"` at the top and references to local `class Foo` / `class Looper` / `instance Foo Int` / etc. All Type::Con names within the file are bare and refer to local types or primitives. Under the new rule: - Validator runs: `Foo` resolves locally; `Int` is primitive; no cross-module refs anywhere. Accepted unchanged. - AST is canonical post-parse. No rewriting, no hash shift. This file's hash is bit-identical pre- and post-milestone. ### Example 2: cross-module file (migration target) A user-written fixture (e.g. iter 23.3 Task 4's `examples/compare_primitives_smoke.ail.json` once recreated) has `"imports": [{ "module": "prelude" }]` and a `Term::Ctor { type_name: "Ordering", ctor: "LT" }` — bare cross-module reference. Today this passes through the iter 23.1.3 imports-fallback. Under the new rule: - Validator rejects: `Ordering` is bare, not in this module's local types, not a primitive → `BareCrossModuleTypeRef { module: "compare_primitives_smoke", name: "Ordering", candidates: ["prelude.Ordering"] }`. - Migration tool rewrites: `Term::Ctor { type_name: "prelude.Ordering", ctor: "LT" }`. Diff is minimal — just the qualifier prepended. - Post-migration: validator passes; typechecker sees the qualified type, looks up `prelude.Ordering` directly in `env.types`, no fallback needed. ### Example 3: the iter 23.3 bug, post-milestone Prelude's `compare` method's return type in `.ail.json`: ```json { "k": "fn", ..., "ret": { "k": "con", "name": "Ordering" }, ... } ``` This is *prelude's own file* — `Ordering` is local. Bare. Accepted unchanged. Hash bit-identical. When user-side typechecker pulls this signature across the module boundary (`compare 1 2` in module `compare_primitives_smoke`), the `qualify_local_types` helper rewrites the bare `Ordering` to `prelude.Ordering` *in the consumer's typecheck context*. The user-side scrutinee's type becomes `prelude.Ordering`. Pattern `LT` resolves against that qualified type's TypeDef directly. No mismatch. **Bug structurally closed.** The fix: ensure `qualify_local_types` runs uniformly at every cross-module fn-lookup site (today's coverage is partial; ct.2 audits and extends). ## Error handling One new diagnostic at workspace load: - `WorkspaceLoadError::BareCrossModuleTypeRef { module, name, candidates }`: "Module `M` contains bare type name `T` that does not resolve to a local type. AILang's `.ail.json` requires cross-module type references to be qualified. Candidates from imports: `[]`. Run the migration tool to fix legacy fixtures." - `WorkspaceLoadError::BadCrossModuleTypeRef { module, name }`: "Module `M` references qualified type `.` but `` is not a known module (or does not declare a type named ``)." Existing typechecker diagnostics (`UnknownType`, `UnknownCtor`, `PatternTypeMismatch`) keep their semantics but fire less often — many ambiguity cases are caught earlier by the validator. ## Testing strategy - **Schema validator unit tests** in `crates/ailang-core/src/workspace.rs::mod tests`: - JSON with `Type::Con { name: "LocalType" }` and a matching local TypeDef: accepted. - JSON with `Type::Con { name: "Ordering" }` and no local Ordering, no imports: rejected with `BareCrossModuleTypeRef`, empty candidates. - JSON with `Type::Con { name: "Ordering" }` and prelude imported: rejected with `BareCrossModuleTypeRef`, candidates = `["prelude.Ordering"]`. - JSON with `Type::Con { name: "Int" }` (primitive): accepted. - JSON with `Type::Con { name: "prelude.Ordering" }` and prelude imported: accepted. - JSON with `Type::Con { name: "Mystery.Type" }` and `Mystery` not a known module: rejected with `BadCrossModuleTypeRef`. - Nested qualification in args (e.g. `List`) walks correctly. - **Migration tool tests**: synthetic workspace with a bare cross-module ref → migrated form has the qualifier. Idempotent (re-running produces the same result). - **Form-B prose round-trip tests**: prose with bare-local type refs → parsed `.ail.json` keeps them bare (they ARE local) → printed prose round-trips byte-stable. Cross-module qualified refs round- trip verbatim through both directions. - **Existing workspace integration tests**: pass with bit-identical hashes for intra-module-only fixtures; re-baselined hashes for the small set of cross-module fixtures affected by migration. - **Iter 23.3 Task 4 fixture** (`examples/compare_primitives_smoke.ail.json`, recreated post-migration with qualified `prelude.Ordering`): compiles, runs, prints `"1\n2\n3\n1\n2\n3\n1\n2\n3\n"`. - **Bench scripts**: run at milestone close. Hypothesis: no measurable regression — validator is constant-time per type ref, removed fallbacks were not in the hot path. ## Acceptance criteria 1. `load_workspace` validates every `Type::Con` per the rule above. Bare cross-module refs and bad qualified refs are rejected with clear diagnostics naming the offending module + type + candidates. 2. Every fixture under `examples/` that previously relied on imports-fallback for bare cross-module refs is migrated to the qualified form. The migration is a single bulk commit produced by the migration tool, not hand-edited. 3. The four obsolete mechanisms in Components are deleted (NOT retained as defensive guards): - `Pattern::Ctor` imports-fallback (iter 15a, `~lib.rs:2486-2521`) - `Term::Ctor` synth imports-fallback (iter 23.1.3, `~lib.rs:1979-2025`) - `lookup_ctor_by_type` codegen imports-fallback (iter 23.1.4) - `apply_per_module_ctor_index_overlay` in mono (iter 23.2 fix `84dcc46`) Code search confirms. 4. `qualify_local_types` (iter 15a, ~lib.rs:1848) STAYS and is audited for uniform application at all cross-module fn-lookup sites (ct.2). The codegen `lower_workspace_inner` implicit-prelude import_map (`be882c4`) STAYS — different problem (fn names). 5. `cargo build --workspace && cargo test --workspace` is FULL green at milestone close. 6. Iter 23.3 Task 4+5 resume after this milestone and complete without any further canonical-form-related compiler changes. 7. DESIGN.md amended: - Decision 2 (content-addressed defs) gains the canonical-form rule: "Within a `.ail.json`, bare `Type::Con` names are local to the file's module; cross-module references MUST be qualified. Primitives are bare and have no module." - The "hashes stay bit-identical" promises across DESIGN.md are reviewed. Most intra-module fixtures' hashes do not shift under this migration; the few cross-module fixtures that DO shift are named explicitly in the JOURNAL entry. 8. JOURNAL entry documents the migration as a targeted canonical-form tightening: most fixtures unchanged, a small set of cross-module fixtures rewritten and re-baselined. 9. The three bench scripts exit 0 or any non-zero is ratified per audit-skill rules. ## Out of scope - **Term-level names** (function names, var names, pattern ctor names like `LT`): unchanged. Pattern ctor names resolve against the scrutinee's type's TypeDef (which is now unambiguous: bare = local-to-this-file, qualified = explicit cross-module). No imports-fallback needed in the pattern path. - **Class names** (`ClassDef.name`, `InstanceDef.class`, `SuperclassRef.class`, `Constraint.class`): NOT migrated to the module-scoped canonical form in this milestone — but we name the reason honestly rather than dressing it as "different semantics". The asymmetry is structural, not semantic. AILang's class-method resolution today (`ModuleGlobals::class_methods: IndexMap`, `crates/ailang-check/src/lib.rs:983`, consumed by `mono::rewrite_class_method_calls`, `crates/ailang-check/src/mono.rs:657-665`) is **name-driven**: the call `eq x y` is resolved by looking up `"eq"` and getting back exactly one `ClassMethodEntry` carrying its `class_name` and `defining_module`. The class is reverse-inferred from the method. That single-entry guarantee is what `WorkspaceLoadError::MethodNameCollision` (iter 22b.2, `workspace.rs:472`) enforces at load time. The causal chain is: 1. Class names are bare in the AST. 2. → Class lookup is `BTreeMap` (workspace-flat, bare-keyed). 3. → For method resolution to be tractable from a bare `Term::Var { name: "eq" }`, the method name must resolve to a unique class. 4. → `MethodNameCollision` enforces uniqueness as a load-time invariant. In other words: `MethodNameCollision` is the load-time constraint that makes bare class names viable. Qualifying class names in the AST would not pay off in isolation — it would force step 3 to be replaced with type-driven dispatch (look up `eq` candidates by argument type, Haskell-style), which is a Designfeld of its own with implications for inference, ambiguity rules, and diagnostic shape. That is out of scope here; this milestone is canonical type names, not a typeclass-dispatch overhaul. What this spec DOES do for class-reference fields (`InstanceDef.class`, `SuperclassRef.class`, `Constraint.class`): ct.1's validator rejects *qualified* forms (containing `'.'`) on those fields. The schema does not currently model qualified class names; the rejection prevents half-migrated files from silently loading. `ClassDef.name` itself remains bare (the file-top `"name"` is the module context, same as `TypeDef.name`). Forward note: if AILang ever revisits name-driven method dispatch — e.g. to allow two libraries to each declare `class Eq` with its own `eq` — the symmetry returns and class names will then need the same module-scoped treatment, along with type-driven method resolution. That is a separate DESIGN-level milestone and is named here so the workaround is visible rather than buried. - **Surface notation in `.ailx`**: bare local names continue to be the LLM-author convention. Prose parser/printer bridge — no surface schema changes. - **`.ail.json` schema version**: stays `"ailang/v0"`. The new validator rule is a tightening of acceptable inputs, not a new schema version. Migration is one-time and tool-assisted. - **External clients**: AILang is pre-1.0. ## Migration impact (one-time, bounded) The migration is much smaller than a "qualify everything" rewrite: - **Intra-module-only fixtures stay bit-identical.** This is the majority of `examples/`. Hash-stable, no rewrite. - **Cross-module fixtures** that today carry bare references (relying on iter 23.1.3's imports-fallback or similar) are rewritten: each bare cross-module Type::Con gains its qualifier. Bytes diff is surgical. - **Hashes shift only for the migrated cross-module fixtures.** Hash-pinned regression tests touching those fixtures re-baselined in ct.4. - **IR snapshot fixtures** under `crates/ail/tests/snapshots/`: if the program in question references prelude types in mangled symbols, those snapshots may shift. Bit-stable otherwise. - **Prose snapshots** (`.prose.txt`): the prose surface didn't carry inline qualifiers for local refs to begin with. They round-trip bit-stable across the migration if the printer correctly trims qualifiers matching the current module name. This is a targeted canonical-form tightening, not a workspace-wide rewrite. The previous form (bare-cross-module-tolerated via imports-fallback) becomes an explicit schema violation; the new form (bare = local, qualified = cross-module) is unambiguous and verifiable at load time.