Files
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

19 KiB

Canonical-Type-Names Tidy Sweep — Design Spec

Date: 2026-05-12 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude

Goal

Close three follow-up todos that the canonical-type-names milestone (ct.1 → ct.4, closed 2026-05-11) explicitly deferred or surfaced. All three touch workspace / env / registry mechanics and share the theme "structural gaps the canonical-type-names milestone declared out-of-scope". Bundling them into one tidy milestone keeps the closeout coherent rather than scattering three orphan tidy patches across the next several weeks.

The three items:

  1. env-overlay shape ratification + narrowing. Decide whether the env's parallel types / ctor_index maps should collapse into one overlay or stay split, anchor the decision in docs/DESIGN.md, and narrow check_in_workspace's per-module overlay rebuild symmetric to ct.3.2's mono-side fix.
  2. Registry.type_def_module re-key. Replace the BTreeMap<String, String> keyed by bare type name with a key that survives cross-module bare-name collisions.
  3. KindMismatch retire. Delete the WorkspaceLoadError::KindMismatch variant and walk_kind_mismatch helper — structurally unreachable post-ct.1, ratified by the already-green test class_param_in_applied_position_fires_canonical_form_rejection at crates/ailang-core/src/workspace.rs:1867.

Items deliberately not in scope: CLI human-mode diagnostic surface for WorkspaceLoadError (different layer — diagnostic routing, not workspace mechanics); Split BadCrossModuleTypeRef (diagnostic shape); Workspace search beyond entry-module's directory (CLI / IO layer); io/print_float .0 fallback (runtime printer asymmetry); Boehm retirement (memory subsystem); rustdoc warning sweep (documentation). Each of these is a coherent tidy in its own right but does not share the workspace-mechanics theme.

Architecture

The milestone is three iterations, ordered from highest-design- substance to lowest. The order is deliberate: the env-overlay decision in ctt.1 is the only one carrying a real choice, so it happens first while the design context is fresh. ctt.2 and ctt.3 are mechanical follow-ups whose shape does not depend on ctt.1.

Iteration ctt.1 — env-overlay shape ratification + narrowing

Design substance: Today Env carries two parallel maps that are rebuilt in lockstep:

  • env.types: IndexMap<String, TypeDef> — bare-type-name → full TypeDef. The owning index for type definitions.
  • env.ctor_index: IndexMap<String, CtorRef> — bare-ctor-name → owning-type-name. A reverse index over env.types.

The "shape question" from docs/journals/2026-05-10 ("Audit close: env-construction unify") is: collapse into one overlay (Map<Name, EntryKind> with Type(TypeDef) | Ctor(owner)) or stay split?

Decision: stay split. Reason (semantic, not effort): the two maps have distinct semantic roles. env.types is an owning index — it stores the data. env.ctor_index is a reverse index — it accelerates ctor-name → type-name lookup over the same data. A collapsed variant-keyed map would conflate these roles and force every consumer to discriminate EntryKind at the call site. The current split lets type-consumers iterate env.types directly and ctor-consumers query env.ctor_index directly; neither needs to know the other exists.

This rationale gets anchored in docs/DESIGN.md as a new top-level section ## Env construction, placed between the existing ## Convention: qualified cross-module references (docs/DESIGN.md:1941) and ## Data model (docs/DESIGN.md:1959). DESIGN.md today has no env / overlay section, so this iter introduces the heading. The initial body is one short paragraph naming the split (env.types owning vs. env.ctor_index reverse-index) with the rationale above; future env / overlay mechanics can extend the section without re-anchoring. Future tidies cannot re-litigate the split without first amending this section.

Code change: one new pinning test. The DESIGN.md anchor is the primary deliverable, plus the roadmap close. The iter also adds one positive test that pins the load-bearing DuplicateCtor consumer, because the in-band check at crates/ailang-check/src/lib.rs:1366-1376 has no in-tree test ratifying it today (the variant is emitted in the production code path but no fixture currently triggers it). The pinning test is what makes the ratification load-bearing rather than documentation-of-belief — the asymmetry-with-mono-side argument rests on this consumer being real.

Inspection of the existing per-module rebuild at crates/ailang-check/src/lib.rs:1342-1384 confirms both halves are load-bearing:

  • env.types.clear() + rebuild: needed for the local DuplicateType diagnostic at lib.rs:1358-1364 and for bare-name Type::Con lookup against the current module.
  • env.ctor_index.clear() + rebuild: emitted into the in-band DuplicateCtor diagnostic at lib.rs:1366-1376, which consults the rebuilt per-module env.ctor_index to detect duplicates within the current module. The new pinning test is the consumer evidence.

The ct.3.2 mono-side fix narrowed the mono overlay because the mono-side env.ctor_index was only feeding runtime ctor lookup (type-driven post-ct.2.2). The check-side overlay is shaped differently — it serves the workspace-build-time duplicate diagnostic — so the analogous narrowing does not apply.

The roadmap todo "check_in_workspace per-module overlay narrowing" closes via this finding: the rebuild stays, the asymmetry with the mono-side is intentional and now documented. The roadmap todo "types / ctor_index overlay shape question" closes via the DESIGN.md anchor recording the split rationale.

If a future recon surfaces a non-DuplicateCtor consumer of the post-rebuild env.ctor_index that could survive removal of the per-module rebuild, the question re-opens; until then the ratification holds.

Iteration ctt.2 — Registry.type_def_module re-key

Bug being closed: crates/ailang-core/src/workspace.rs:106 declares type_def_module: BTreeMap<String, String>. Today this map is bare-name → defining-module. The doc-comment at workspace.rs:97-105 flags the issue: if two modules each declare type Foo, the second insert at workspace.rs:547 silently overwrites the first; subsequently normalize_type_for_registry (workspace.rs:892) qualifies Foo to whichever module won the insert race.

For the current corpus (every in-tree fixture uses distinct bare type names across modules) the bug is dormant. The proper fix is explicitly written into the doc-comment: re-key as (owning_module, bare_name) -> defining_module and thread the calling module through every consumer site.

Re-keying choice. Two shapes are conceivable:

  • BTreeMap<(String, String), String> keyed by (owning_module, bare_name) — straightforward extension.
  • BTreeMap<String, Vec<(String, String)>> keyed by bare name, value being a list of (owner, defining_module) pairs — preserves the bare-name-as-primary-lookup intuition but forces every consumer to handle the multi-candidate case.

Decision: tuple key. Reason (semantic): the function the map serves — "given a type reference written in module M, where is its type defined?" — already requires the owning module M as input. The tuple key makes that input explicit at the API surface. The list-valued alternative would defer multi-candidate handling to every consumer.

Consumer site count. grep "type_def_module" returns 13 hits in workspace.rs. Most are inside the workspace-load function where the calling module is local context (loop variable mod_name), so threading is trivial. The interesting site is normalize_type_for_registry at workspace.rs:879-928, which today takes type_def_module: &BTreeMap<String, String> as a parameter. The signature changes to accept the calling module as well, and the map type changes to &BTreeMap<(String, String), String>. All call sites already have the calling module in scope (the workspace-load loop threads it through mod_name).

The Registry::normalize_type_for_lookup method at workspace.rs:121-123 exposes normalize_type_for_registry publicly without a calling-module argument. The signature gains a caller_module: &str argument. The two test-only call sites at workspace.rs:2525, 2549 use synthetic modules; both update trivially.

Test: add a positive fixture where two modules each declare type Foo with distinct ctors, an instance on each, and confirm both instances resolve correctly under registry lookup. This catches the regression: under the old bare-name key, one of the two instances would silently lose its registry entry.

Iteration ctt.3 — KindMismatch retire

Path being deleted:

  • WorkspaceLoadError::KindMismatch variant (crates/ailang-core/src/workspace.rs:259-264).
  • walk_kind_mismatch helper (crates/ailang-core/src/workspace.rs:834-854).
  • Dispatch site in validate_classdefs (crates/ailang-core/src/workspace.rs:786-795).
  • Display arm in crates/ail/src/main.rs:1163 for the human-mode diagnostic.

Ratification: Already-green test class_param_in_applied_position_fires_canonical_form_rejection (crates/ailang-core/src/workspace.rs:1867-1888) demonstrates that the malformed shape class C f { foo : f Int -> f Int } fires BareCrossModuleTypeRef before validate_classdefs runs. This is the structural-unreachability claim made by the roadmap entry, ratified by a workspace-loaded test that currently passes.

The test must keep passing after the deletion. It asserts on BareCrossModuleTypeRef, not KindMismatch, so no test edit is required.

Defensive vs. retired: The doc comment at workspace.rs:1858-1866 reads "stays in the codebase as dead-but-defensive code; a future tidy may retire it." This iter is that future tidy. The "defensive" framing was a deliberate hedge during ct.1 in case a canonical-form bug let malformed shapes through later — none has surfaced. Carrying the dead branch indefinitely is itself a hazard: future refactors of the workspace error-enum touch shapes that no code path produces. Retire now, before the dead branch accumulates churn.

If a future canonical-form bug does let a malformed shape through, the right repair is to fix the canonical-form validator, not to resurrect KindMismatch. The diagnostic that fires today (BareCrossModuleTypeRef) is structurally correct: a class param f used as f Int is, post-ct.1, a reference to a non-existent type f in the owning module — that is a bare-cross-module type ref. The earlier KindMismatch diagnostic was a less-precise pre-ct.1 framing.

Components

Files touched per iter

ctt.1 — env-overlay shape ratification + DuplicateCtor pin:

  • docs/DESIGN.md — new top-level section ## Env construction inserted between ## Convention: qualified cross-module references (line 1941) and ## Data model (line 1959). Initial body: one paragraph naming the env.types / env.ctor_index split with the owning-vs-reverse-index rationale.
  • crates/ailang-check/tests/ — new test (path TBD by planner — candidate: a new file duplicate_ctor_pin.rs, or appended to an existing fixture-driven test module) that builds a one- module fixture with two Def::Types declaring same-named ctors and asserts check_workspace returns CheckError::DuplicateCtor { ctor, a, b }. The exact assertion path follows the existing CheckError-asserting test conventions in the crate.
  • docs/roadmap.md — strike both relevant todo lines ("types / ctor_index overlay shape question" and "check_in_workspace per-module overlay narrowing") with a reference to docs/journals/<this iter>.md.

ctt.2 — Registry.type_def_module re-key:

  • crates/ailang-core/src/workspace.rs:106 — field type change to BTreeMap<(String, String), String>.
  • crates/ailang-core/src/workspace.rs:121-123Registry::normalize_type_for_lookup gains caller_module: &str.
  • crates/ailang-core/src/workspace.rs:537, 547 — Pass 1 collection: insert (mod_name.clone(), t.name.clone()) instead of t.name.clone().
  • crates/ailang-core/src/workspace.rs:656-657type_def_module.get(&type_repr) becomes a per-module-aware lookup (the call-site context provides mod_name).
  • crates/ailang-core/src/workspace.rs:879-928normalize_type_for_registry signature + body adapt to tuple key + caller_module argument.
  • crates/ailang-core/src/workspace.rs:2525, 2549 — test-only call sites update.
  • crates/ailang-core/src/workspace.rs:97-105 — doc-comment flagging the bug is replaced with the new shape's invariant.
  • New test fixture: examples/ct_tidy_cross_module_foo_collision.ail.json (or analogous), exercising two-module bare-name collision.
  • crates/ailang-core/tests/ or crates/ail/tests/e2e.rs — test that exercises the new fixture and asserts both instances resolve correctly.

ctt.3 — KindMismatch retire:

  • crates/ailang-core/src/workspace.rs:259-264 — variant deletion.
  • crates/ailang-core/src/workspace.rs:786-795 — dispatch site deletion (the walk_kind_mismatch(&method.ty, &c.param) call inside validate_classdefs).
  • crates/ailang-core/src/workspace.rs:834-854 — helper deletion.
  • crates/ail/src/main.rs:1163 — Display arm deletion.
  • crates/ailang-core/src/workspace.rs:1858-1866 — the "dead-but-defensive" doc comment on the existing test gets dropped (the test stays; its rationale is now "the canonical-form rejection is the path", which the test name already states).

Files NOT touched

  • crates/ailang-check/src/lib.rs:1342-1384 — the per-module overlay rebuild stays as-is after ctt.1's recon confirms DuplicateCtor depends on it. The narrowing question closes via DESIGN.md ratification, not code change. If planner-recon for ctt.1 surfaces a consumer that would survive narrowing, this changes; default expectation is no edit here.
  • crates/ailang-codegen/ — no codegen edits. None of the three items touch IR or runtime.
  • Snapshots — no IR-shape snapshots are affected. The bench baseline is not affected (no codepath frequency change).

Data flow

The three iters do not interact at the data level:

  • ctt.1 records a decision in DESIGN.md; no code-side data flow change.
  • ctt.2 changes the registry's internal key shape but preserves the public Registry::normalize_type_for_lookup semantics for every call site that already has its calling module in scope. External canonical-form behaviour is unchanged for the current corpus (which has no bare-name collisions); the fix is load-bearing only when a future workspace introduces a collision.
  • ctt.3 deletes an unreachable branch; no data flow.

Canonical-form bytes: unchanged. None of the three iters touches serialised AST shapes. ctt.2's registry-key change is internal — the canonical-form output of normalize_type_for_registry for any valid input is the same as today (the bug surfaces only on the malformed-collision input that the current corpus doesn't contain, and the new behaviour is "qualify to the correct owner" rather than "qualify to whichever module won the race", which is by definition a fix not a shape change).

Error handling

ctt.1: no new diagnostics. The ratification is documentation- only at the analyser level.

ctt.2: no new diagnostics. The DuplicateInstance / OrphanInstance paths at workspace.rs:682, 660 already cover the collision-error space; the re-key fixes the silent collision by ensuring the right registry entry survives, which means a previously-masked instance becomes visible to the existing DuplicateInstance detector when its canonical-form key matches another (and is not falsely promoted to "duplicate" when it actually belongs to a different owner).

ctt.3: one diagnostic variant goes away. The Display path in main.rs:1163 deletes the human-mode [kind-mismatch] line; the structurally-identical class-param-in-applied-position malformed shape now produces [bare-cross-module-type-ref] instead. Existing user-facing diagnostic for the canonical malformed-fixture is the latter (the existing test confirms this).

Testing strategy

ctt.1:

  • New pinning test: one-module fixture with two Def::Types declaring same-named ctors. Asserts check_workspace returns CheckError::DuplicateCtor { ctor, a, b } with a and b naming the two types. Catches any future regression that silently removes the per-module env.ctor_index rebuild — if the rebuild goes away, this test goes red, which is exactly the load-bearing-asymmetry-argument's safety net.
  • cargo test --workspace continues to pass with all other tests unchanged.
  • DESIGN.md anchor presence verified manually (no schema-drift test exists for the env section yet — see roadmap item "design_schema_drift.rs fidelity widening", out-of-scope here).
  • Roadmap close: both relevant lines struck through.

ctt.2:

  • New fixture: two modules each declaring type Foo with distinct ctors; an instance on each. Test: both instances registerable, both retrievable by their canonical-form registry key, no DuplicateInstance false positive, no silent overwrite.
  • Existing test suite must continue to pass (no current fixture exercises the collision case, so no existing test regresses).

ctt.3:

  • Existing test class_param_in_applied_position_fires_canonical_form_rejection (crates/ailang-core/src/workspace.rs:1867) continues to pass, asserting BareCrossModuleTypeRef on the malformed fixture.
  • cargo test --workspace continues to pass (no other test references KindMismatchgrep KindMismatch returns the three deletion sites only).
  • cargo build --workspace succeeds (no orphan match arms, no dead-code warnings introduced).

Workspace-wide gate (all three iters):

  • bench/cross_lang.py, bench/compile_check.py, bench/check.py all green. No metric should plausibly shift, but the audit at milestone close confirms.

Acceptance criteria

  • docs/DESIGN.md carries a new top-level section ## Env construction (placed between ## Convention: qualified cross-module references and ## Data model) whose initial paragraph anchors the env.types / env.ctor_index split with the semantic rationale (owning index vs. reverse index).
  • A new pinning test in crates/ailang-check/tests/ (path finalised in planning) asserts CheckError::DuplicateCtor fires for a one-module fixture with two Def::Types declaring same-named ctors — ratifies the consumer the ctt.1 ratification rests on.
  • Registry.type_def_module key is (String, String), value is String. Every consumer site provides the calling module at the call site. A regression test exercises two-module bare-name collision and confirms both instances resolve correctly.
  • WorkspaceLoadError::KindMismatch, walk_kind_mismatch, its dispatch site, and its main.rs Display arm are deleted. The existing test class_param_in_applied_position_fires_canonical_form_rejection still passes, asserting BareCrossModuleTypeRef.
  • cargo test --workspace, bench/cross_lang.py, bench/compile_check.py, bench/check.py all green at milestone close.
  • docs/roadmap.md has the three relevant todo lines struck through with reference to this spec or the per-iter journals.
  • docs/WhatsNew.md carries one entry covering the milestone in user-as-reader prose (no internals, no iter codes).