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.
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:
- env-overlay shape ratification + narrowing. Decide whether
the env's parallel
types/ctor_indexmaps should collapse into one overlay or stay split, anchor the decision indocs/DESIGN.md, and narrowcheck_in_workspace's per-module overlay rebuild symmetric to ct.3.2's mono-side fix. Registry.type_def_modulere-key. Replace theBTreeMap<String, String>keyed by bare type name with a key that survives cross-module bare-name collisions.KindMismatchretire. Delete theWorkspaceLoadError::KindMismatchvariant andwalk_kind_mismatchhelper — structurally unreachable post-ct.1, ratified by the already-green testclass_param_in_applied_position_fires_canonical_form_rejectionatcrates/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 overenv.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 localDuplicateTypediagnostic atlib.rs:1358-1364and for bare-nameType::Conlookup against the current module.env.ctor_index.clear()+ rebuild: emitted into the in-bandDuplicateCtordiagnostic atlib.rs:1366-1376, which consults the rebuilt per-moduleenv.ctor_indexto 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::KindMismatchvariant (crates/ailang-core/src/workspace.rs:259-264).walk_kind_mismatchhelper (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:1163for 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 constructioninserted between## Convention: qualified cross-module references(line 1941) and## Data model(line 1959). Initial body: one paragraph naming theenv.types/env.ctor_indexsplit with the owning-vs-reverse-index rationale.crates/ailang-check/tests/— new test (path TBD by planner — candidate: a new fileduplicate_ctor_pin.rs, or appended to an existing fixture-driven test module) that builds a one- module fixture with twoDef::Types declaring same-named ctors and assertscheck_workspacereturnsCheckError::DuplicateCtor { ctor, a, b }. The exact assertion path follows the existingCheckError-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 todocs/journals/<this iter>.md.
ctt.2 — Registry.type_def_module re-key:
crates/ailang-core/src/workspace.rs:106— field type change toBTreeMap<(String, String), String>.crates/ailang-core/src/workspace.rs:121-123—Registry::normalize_type_for_lookupgainscaller_module: &str.crates/ailang-core/src/workspace.rs:537, 547— Pass 1 collection: insert(mod_name.clone(), t.name.clone())instead oft.name.clone().crates/ailang-core/src/workspace.rs:656-657—type_def_module.get(&type_repr)becomes a per-module-aware lookup (the call-site context providesmod_name).crates/ailang-core/src/workspace.rs:879-928—normalize_type_for_registrysignature + body adapt to tuple key +caller_moduleargument.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/orcrates/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 (thewalk_kind_mismatch(&method.ty, &c.param)call insidevalidate_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 confirmsDuplicateCtordepends 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_lookupsemantics 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. Assertscheck_workspacereturnsCheckError::DuplicateCtor { ctor, a, b }withaandbnaming the two types. Catches any future regression that silently removes the per-moduleenv.ctor_indexrebuild — if the rebuild goes away, this test goes red, which is exactly the load-bearing-asymmetry-argument's safety net. cargo test --workspacecontinues 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 Foowith distinct ctors; an instance on each. Test: both instances registerable, both retrievable by their canonical-form registry key, noDuplicateInstancefalse 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, assertingBareCrossModuleTypeRefon the malformed fixture. cargo test --workspacecontinues to pass (no other test referencesKindMismatch—grep KindMismatchreturns the three deletion sites only).cargo build --workspacesucceeds (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.pyall green. No metric should plausibly shift, but the audit at milestone close confirms.
Acceptance criteria
docs/DESIGN.mdcarries a new top-level section## Env construction(placed between## Convention: qualified cross-module referencesand## Data model) whose initial paragraph anchors theenv.types/env.ctor_indexsplit with the semantic rationale (owning index vs. reverse index).- A new pinning test in
crates/ailang-check/tests/(path finalised in planning) assertsCheckError::DuplicateCtorfires for a one-module fixture with twoDef::Types declaring same-named ctors — ratifies the consumer the ctt.1 ratification rests on. Registry.type_def_modulekey is(String, String), value isString. 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 itsmain.rsDisplay arm are deleted. The existing testclass_param_in_applied_position_fires_canonical_form_rejectionstill passes, assertingBareCrossModuleTypeRef.cargo test --workspace,bench/cross_lang.py,bench/compile_check.py,bench/check.pyall green at milestone close.docs/roadmap.mdhas the three relevant todo lines struck through with reference to this spec or the per-iter journals.docs/WhatsNew.mdcarries one entry covering the milestone in user-as-reader prose (no internals, no iter codes).