# 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 `"<owning_module>.<TypeName>"`
  — 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 `<owner>.<type>` where `<owner>`
   is a known module (the workspace's module map) AND `<type>` is a
   type def in `<owner>`. 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: `[<list>]`.
  Run the migration tool to fix legacy fixtures."

- `WorkspaceLoadError::BadCrossModuleTypeRef { module, name }`:
  "Module `M` references qualified type `<owner>.<type>` but `<owner>`
  is not a known module (or does not declare a type named `<type>`)."

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<prelude.Ordering>`)
    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<String,
  ClassMethodEntry>`, `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<String, &ClassDef>` (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.

