Files
AILang/docs/specs/0007-canonical-type-names.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

18 KiB

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:

{ "k": "fn", ..., "ret": { "k": "con", "name": "Ordering" }, ... }

This is prelude's own fileOrdering 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.