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.
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.namealways 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:
- 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>. ElseWorkspaceLoadError::BadCrossModuleTypeRef. - Else if
nameis a primitive (Int/Bool/Str/Unit/Float): accepted, no further check. - Else (bare non-primitive):
namemust be a type def inM's owndefs. ElseWorkspaceLoadError::BareCrossModuleTypeRef { module: M, name, candidates }— candidates lists the qualified forms found inM'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:
Fooresolves locally;Intis 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:
Orderingis 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.Orderingdirectly inenv.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 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 }: "ModuleMcontains bare type nameTthat does not resolve to a local type. AILang's.ail.jsonrequires cross-module type references to be qualified. Candidates from imports:[<list>]. Run the migration tool to fix legacy fixtures." -
WorkspaceLoadError::BadCrossModuleTypeRef { module, name }: "ModuleMreferences 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 withBareCrossModuleTypeRef, empty candidates. - JSON with
Type::Con { name: "Ordering" }and prelude imported: rejected withBareCrossModuleTypeRef, 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" }andMysterynot a known module: rejected withBadCrossModuleTypeRef. - Nested qualification in args (e.g.
List<prelude.Ordering>) walks correctly.
- JSON with
-
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.jsonkeeps 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 qualifiedprelude.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
-
load_workspacevalidates everyType::Conper the rule above. Bare cross-module refs and bad qualified refs are rejected with clear diagnostics naming the offending module + type + candidates. -
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. -
The four obsolete mechanisms in Components are deleted (NOT retained as defensive guards):
Pattern::Ctorimports-fallback (iter 15a,~lib.rs:2486-2521)Term::Ctorsynth imports-fallback (iter 23.1.3,~lib.rs:1979-2025)lookup_ctor_by_typecodegen imports-fallback (iter 23.1.4)apply_per_module_ctor_index_overlayin mono (iter 23.2 fix84dcc46)
Code search confirms.
-
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 codegenlower_workspace_innerimplicit-prelude import_map (be882c4) STAYS — different problem (fn names). -
cargo build --workspace && cargo test --workspaceis FULL green at milestone close. -
Iter 23.3 Task 4+5 resume after this milestone and complete without any further canonical-form-related compiler changes.
-
DESIGN.md amended:
- Decision 2 (content-addressed defs) gains the canonical-form
rule: "Within a
.ail.json, bareType::Connames 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.
- Decision 2 (content-addressed defs) gains the canonical-form
rule: "Within a
-
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.
-
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 bymono::rewrite_class_method_calls,crates/ailang-check/src/mono.rs:657-665) is name-driven: the calleq x yis resolved by looking up"eq"and getting back exactly oneClassMethodEntrycarrying itsclass_nameanddefining_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:- Class names are bare in the AST.
- → Class lookup is
BTreeMap<String, &ClassDef>(workspace-flat, bare-keyed). - → For method resolution to be tractable from a bare
Term::Var { name: "eq" }, the method name must resolve to a unique class. - →
MethodNameCollisionenforces uniqueness as a load-time invariant.
In other words:
MethodNameCollisionis 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 upeqcandidates 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.nameitself remains bare (the file-top"name"is the module context, same asTypeDef.name).Forward note: if AILang ever revisits name-driven method dispatch — e.g. to allow two libraries to each declare
class Eqwith its owneq— 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.jsonschema 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.