Files
AILang/docs/plans/0049-ctt.2.md
T
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

28 KiB

ctt.2 — Registry.type_def_module re-key — Implementation Plan

Parent spec: docs/specs/0021-ct-tidy.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Re-key Registry.type_def_module from BTreeMap<String, String> (bare-name keyed, silently collision-prone) to BTreeMap<(String, String), String> (keyed by (owning_module, bare_name)); thread caller_module: &str through normalize_type_for_registry and its public peer Registry::normalize_type_for_lookup; update every consumer site; add a regression fixture pair that exercises the bare-name collision shape.

Architecture: RED-first. Task 1 lands a two-module fixture pair where both modules declare type Foo with distinct ctors plus a class-method instance each; today this trips DuplicateInstance because both instances canonicalise to <winner>.Foo after the bare-name overwrite — test asserts successful load + both instances retrievable in the registry, so it goes RED today. Task 2 applies the re-key in one atomic edit pass touching the field, the doc-comment, both pass-1 collection sites, both pass-2 lookup sites, both signatures plus body of normalize_type_for_registry / Registry::normalize_type_for_lookup, the four ailang-check consumer sites at lib.rs and mono.rs, and the in-workspace.rs test at line 2523. Task 3 confirms the new test goes GREEN and the full cargo test --workspace stays green.

Tech Stack: ailang-core (Registry, workspace.rs), ailang-check (lib.rs:1640 instance lookup at NoInstance-check; mono.rs:121, 624, 1175 instance lookups in monomorphisation).

Files this plan creates or modifies:

  • Create: examples/ctt2_collision_main.ail.json — entry module: imports ctt2_collision_lib; declares type Foo = MkMain; declares class MyC a { op : a -> Int }; declares instance MyC main.Foo { op = lam _. 1 }.
  • Create: examples/ctt2_collision_lib.ail.json — library module: imports the entry-module's class via qualifier (main.MyC); declares type Foo = MkLib; declares instance main.MyC lib.Foo { op = lam _. 2 }.
  • Create: crates/ailang-core/tests/ctt2_registry_rekey.rs — loads the fixture pair via load_workspace, asserts ws.registry.entries contains two (MyC, type_hash) entries with distinct keys (one for main.Foo, one for lib.Foo), each pointing to its own defining-module.
  • Modify: crates/ailang-core/src/workspace.rs:97-106 — doc-comment (:97-105) replaced with new invariant; field type at :106 changes to BTreeMap<(String, String), String>.
  • Modify: crates/ailang-core/src/workspace.rs:121-123Registry::normalize_type_for_lookup gains caller_module: &str.
  • Modify: crates/ailang-core/src/workspace.rs:537, 546-548 — pass-1 collection: declaration changes to tuple-keyed map; insert uses (mod_name.clone(), t.name.clone()) as the key.
  • Modify: crates/ailang-core/src/workspace.rs:656-659 — pass-2 coherence-check lookup uses (mod_name.clone(), type_repr.clone()).
  • Modify: crates/ailang-core/src/workspace.rs:678-680 — pass-2 canonical-form normalisation passes mod_name as caller_module.
  • Modify: crates/ailang-core/src/workspace.rs:879-928normalize_type_for_registry signature gains caller_module: &str; the Type::Con arm's bare-name lookup uses the tuple key; recursive calls thread caller_module through; doc-comment at :879-883 updated.
  • Modify: crates/ailang-core/src/workspace.rs:2523-2566 — the existing ct1_5a_normalize_recurses_into_forall_constraints test updates its inline map type, insert key, and call to pass a synthetic caller module.
  • Modify: crates/ailang-check/src/lib.rs:1639-1640normalize_type_for_lookup(&r_ty) becomes normalize_type_for_lookup(env.current_module.as_str(), &r_ty).
  • Modify: crates/ailang-check/src/mono.rs:120-121ws_owned.registry.normalize_type_for_lookup(type_) becomes ws_owned.registry.normalize_type_for_lookup(defining_module.as_str(), type_).
  • Modify: crates/ailang-check/src/mono.rs:623-624env.workspace_registry.normalize_type_for_lookup(&r_ty) becomes env.workspace_registry.normalize_type_for_lookup(module_name, &r_ty).
  • Modify: crates/ailang-check/src/mono.rs:1174-1175env.workspace_registry.normalize_type_for_lookup(&r_ty) becomes env.workspace_registry.normalize_type_for_lookup(module_name, &r_ty).

Design notes (settled by Boss pre-plan):

  • At workspace.rs:656-659, caller_module is mod_name (the instance's own module). Rationale: "from this instance's view, where is the type it's on defined?" Under coherence, that is either the instance's module (when type lives there) or the class's module (matched via the existing class_mod == mod_name leg). The <primitive-or-unknown> fallback is preserved.
  • At mono.rs:121, caller_module is defining_module (the instance's defining module, lifted from MonoTarget::ClassMethod). Rationale: post-ct.1 the type_ field is already canonical-form (qualified), so the bare-name lookup branch at normalize_type_for_registry's line 892-equivalent is dead at this site; the threaded caller_module is a structurally-correct no-op. Extending MonoTarget with a new field would be ctt.2-out-of-scope plumbing.
  • At mono.rs:624 and :1175, caller_module is module_name (the enclosing function parameter to collect_mono_targets / collect_residuals_ordered). Rationale: that is the module the call was authored in.
  • At lib.rs:1640, caller_module is env.current_module.as_str(). Rationale: env.current_module carries the active per-module overlay during check_fn, set at lib.rs:1342-1357's caller.

Task 1: RED — fixture pair + regression test

Files:

  • Create: examples/ctt2_collision_main.ail.json
  • Create: examples/ctt2_collision_lib.ail.json
  • Create: crates/ailang-core/tests/ctt2_registry_rekey.rs

Goal: introduce a workspace that exercises the bare-name collision the spec fixes. Today both modules' bare-Foo instance canonicalises to <winner>.Foo via normalize_type_for_registry, so the second-registered instance trips workspace.rs:683's DuplicateInstance check. The new test asserts successful load + both (MyC, type_hash) entries in the registry → goes RED today.

  • Step 1: Write examples/ctt2_collision_lib.ail.json

Path: examples/ctt2_collision_lib.ail.json

{
  "schema": "ailang/v0",
  "name": "ctt2_collision_lib",
  "imports": [{ "module": "ctt2_collision_main" }],
  "defs": [
    {
      "kind": "type",
      "name": "Foo",
      "ctors": [{ "name": "MkLib", "fields": [] }]
    },
    {
      "kind": "instance",
      "class": "ctt2_collision_main.MyC",
      "type": { "k": "con", "name": "Foo" },
      "methods": [
        {
          "name": "op",
          "body": {
            "t": "lam",
            "params": ["_x"],
            "paramTypes": [{ "k": "con", "name": "Foo" }],
            "retType": { "k": "con", "name": "Int" },
            "body": { "t": "lit", "lit": { "kind": "int", "value": 2 } }
          }
        }
      ]
    }
  ]
}
  • Step 2: Write examples/ctt2_collision_main.ail.json

Path: examples/ctt2_collision_main.ail.json

{
  "schema": "ailang/v0",
  "name": "ctt2_collision_main",
  "imports": [{ "module": "ctt2_collision_lib" }],
  "defs": [
    {
      "kind": "class",
      "name": "MyC",
      "param": "a",
      "methods": [
        {
          "name": "op",
          "type": {
            "k": "fn",
            "params": [{ "k": "var", "name": "a" }],
            "ret": { "k": "con", "name": "Int" },
            "effects": []
          }
        }
      ]
    },
    {
      "kind": "type",
      "name": "Foo",
      "ctors": [{ "name": "MkMain", "fields": [] }]
    },
    {
      "kind": "instance",
      "class": "MyC",
      "type": { "k": "con", "name": "Foo" },
      "methods": [
        {
          "name": "op",
          "body": {
            "t": "lam",
            "params": ["_x"],
            "paramTypes": [{ "k": "con", "name": "Foo" }],
            "retType": { "k": "con", "name": "Int" },
            "body": { "t": "lit", "lit": { "kind": "int", "value": 1 } }
          }
        }
      ]
    }
  ]
}

Note: the entry-module ctt2_collision_main imports ctt2_collision_lib (lib-side type definition reachable), and ctt2_collision_lib imports back the main module to see MyC. Two-way import is supported; workspace loader DFS handles cycles at module-name granularity.

  • Step 3: Write the regression test

Path: crates/ailang-core/tests/ctt2_registry_rekey.rs

//! Regression for the `Registry.type_def_module` re-key (ctt.2).
//!
//! Two modules each declare `type Foo` with distinct ctors and
//! provide an `instance MyC Foo`. Pre-ctt.2, both bare `Foo`s
//! collide on the bare-name `BTreeMap<String, String>` key —
//! `normalize_type_for_registry` qualifies both to whichever
//! module won the insert race, and the loser's instance trips
//! a false `DuplicateInstance`. Post-ctt.2, the tuple-keyed map
//! distinguishes the two `Foo`s by owning module; both instances
//! register cleanly under distinct canonical keys.
//!
//! This test goes red today (DuplicateInstance) and green after
//! the re-key.

use ailang_core::canonical;
use ailang_core::ast::Type;
use ailang_core::load_workspace;
use std::path::Path;

fn examples_dir() -> std::path::PathBuf {
    let manifest = env!("CARGO_MANIFEST_DIR");
    Path::new(manifest)
        .parent().expect("CARGO_MANIFEST_DIR has a parent (crates/)")
        .parent().expect("crates has a parent (workspace root)")
        .join("examples")
}

#[test]
fn two_modules_with_same_bare_foo_both_register() {
    let entry = examples_dir().join("ctt2_collision_main.ail.json");
    let ws = load_workspace(&entry)
        .expect("expected workspace load to succeed; both `type Foo` declarations live in distinct modules and must register under distinct canonical keys");

    // Compute the canonical type hashes for the two expected entries.
    let main_foo_hash = canonical::type_hash(&Type::Con {
        name: "ctt2_collision_main.Foo".into(),
        args: vec![],
    });
    let lib_foo_hash = canonical::type_hash(&Type::Con {
        name: "ctt2_collision_lib.Foo".into(),
        args: vec![],
    });

    let main_key = ("MyC".to_string(), main_foo_hash);
    let lib_key = ("MyC".to_string(), lib_foo_hash);

    assert!(
        ws.registry.entries.contains_key(&main_key),
        "registry missing entry for (MyC, ctt2_collision_main.Foo); entries: {:?}",
        ws.registry.entries.keys().collect::<Vec<_>>()
    );
    assert!(
        ws.registry.entries.contains_key(&lib_key),
        "registry missing entry for (MyC, ctt2_collision_lib.Foo); entries: {:?}",
        ws.registry.entries.keys().collect::<Vec<_>>()
    );

    // Defining-module sanity: each entry's defining_module matches
    // the module that wrote the instance.
    let main_entry = &ws.registry.entries[&main_key];
    assert_eq!(
        main_entry.defining_module, "ctt2_collision_main",
        "main-side entry's defining_module mismatched"
    );
    let lib_entry = &ws.registry.entries[&lib_key];
    assert_eq!(
        lib_entry.defining_module, "ctt2_collision_lib",
        "lib-side entry's defining_module mismatched"
    );
}
  • Step 4: Run the test, confirm RED

Run: cargo test -p ailang-core --test ctt2_registry_rekey two_modules_with_same_bare_foo_both_register Expected: FAIL. The most likely failure shape today is either:

  • (a) load_workspace returns Err(DuplicateInstance { class: "MyC", type_repr: "Foo", first_module: <winner>, second_module: <loser> }) because both bare-Foo instances canonicalise to <winner>.Foo and collide on the (MyC, type_hash) key. The test's expect(...) panics with the supplied message.
  • (b) Less likely but possible: the load succeeds but one of the two main.Foo / lib.Foo hashes is missing from ws.registry.entries because both registered under the same key (last-write-wins). One of the two contains_key asserts panics.

If the failure shape is neither (a) nor (b), the spec's diagnosis is wrong and the iter pauses for a fresh read.


Task 2: GREEN — atomic re-key edit pass

Files:

  • Modify: crates/ailang-core/src/workspace.rs (multi-line)
  • Modify: crates/ailang-check/src/lib.rs:1639-1640
  • Modify: crates/ailang-check/src/mono.rs:120-121, 623-624, 1174-1175

One edit pass — between Step 1 and the end of Step 8 the workspace will not compile. That is fine; the Boss is committing the iter as a whole, and any intermediate-step partial state is internal to the implement-phase.

  • Step 1: Update doc-comment + field type at workspace.rs:97-106

old_string:

    /// `N.Foo` to whichever module wins the race. Acceptable for the
    /// current corpus (all in-tree fixtures use distinct bare type
    /// names across modules); revisit if a future workspace breaks the
    /// assumption. Proper fix is to re-key as
    /// `(owning_module, bare_name) -> defining_module` and thread the
    /// calling module through every consumer site — out of ct.1's scope.
    pub type_def_module: BTreeMap<String, String>,

new_string:

    /// Keyed by `(owning_module, bare_name)`, value is the
    /// defining module. The tuple key disambiguates same-named
    /// types declared in different modules — bare `Foo` from
    /// module M is `(M, "Foo")`, bare `Foo` from module N is
    /// `(N, "Foo")`, and the two carry distinct canonical
    /// qualifications under
    /// [`normalize_type_for_registry`]. Pre-ctt.2 the key was
    /// the bare name alone, and a workspace with two `type Foo`
    /// declarations silently overwrote one entry, then tripped
    /// `DuplicateInstance` on the loser-side instance after
    /// both qualified to `<winner>.Foo`.
    pub type_def_module: BTreeMap<(String, String), String>,
  • Step 2: Update Registry::normalize_type_for_lookup signature/body

old_string:

    /// ct.1.5a: produce the canonical form of `t` for registry-key
    /// hashing. Bare-non-primitive `Type::Con` names get qualified to
    /// `<defining_module>.<name>`; already-qualified names stay; bare
    /// names whose defining module is unknown stay as-is.
    /// `Type::Fn`/`Type::Forall`/`Type::Var` recurse / pass through
    /// structurally.
    ///
    /// Every consumer that hashes an `inst.type_`-shaped expression to
    /// look it up in [`Self::entries`] must funnel through this
    /// helper, otherwise the registered-form and the queried-form
    /// disagree on whether the leading qualifier is present.
    pub fn normalize_type_for_lookup(&self, t: &Type) -> Type {
        normalize_type_for_registry(t, &self.type_def_module)
    }

new_string:

    /// ct.1.5a + ctt.2: produce the canonical form of `t` for
    /// registry-key hashing. Bare-non-primitive `Type::Con` names
    /// get qualified to `<defining_module>.<name>`; already-qualified
    /// names stay; bare names whose defining module is unknown stay
    /// as-is. `Type::Fn`/`Type::Forall`/`Type::Var` recurse / pass
    /// through structurally.
    ///
    /// `caller_module` is the module in whose scope `t` was authored.
    /// Bare-name lookups are keyed by `(caller_module, name)`, so a
    /// bare `Foo` written in module M resolves only to M's `Foo`,
    /// never to a same-named type from another module.
    ///
    /// Every consumer that hashes an `inst.type_`-shaped expression
    /// to look it up in [`Self::entries`] must funnel through this
    /// helper, otherwise the registered-form and the queried-form
    /// disagree on whether the leading qualifier is present.
    pub fn normalize_type_for_lookup(&self, caller_module: &str, t: &Type) -> Type {
        normalize_type_for_registry(caller_module, t, &self.type_def_module)
    }
  • Step 3: Update pass-1 collection declaration + insert

Apply Edit 1 to crates/ailang-core/src/workspace.rs:

old_string:

    let mut type_def_module: BTreeMap<String, String> = BTreeMap::new();

new_string:

    let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new();

Apply Edit 2 to crates/ailang-core/src/workspace.rs:

old_string:

                Def::Type(t) => {
                    type_def_module.insert(t.name.clone(), mod_name.clone());
                }

new_string:

                Def::Type(t) => {
                    type_def_module.insert(
                        (mod_name.clone(), t.name.clone()),
                        mod_name.clone(),
                    );
                }
  • Step 4: Update pass-2 coherence-check lookup + canonical-form normalisation

Apply Edit 1 to crates/ailang-core/src/workspace.rs (pass-2 type_mod lookup):

old_string:

                let type_mod = type_def_module
                    .get(&type_repr)
                    .cloned()
                    .unwrap_or_else(|| "<primitive-or-unknown>".into());

new_string:

                let type_mod = type_def_module
                    .get(&(mod_name.clone(), type_repr.clone()))
                    .cloned()
                    .unwrap_or_else(|| "<primitive-or-unknown>".into());

Apply Edit 2 to crates/ailang-core/src/workspace.rs (pass-2 canonical-form hashing):

old_string:

                let type_hash = canonical::type_hash(
                    &normalize_type_for_registry(&inst.type_, &type_def_module),
                );

new_string:

                let type_hash = canonical::type_hash(
                    &normalize_type_for_registry(
                        mod_name,
                        &inst.type_,
                        &type_def_module,
                    ),
                );
  • Step 5: Update normalize_type_for_registry signature, doc-comment, body

old_string:

/// `type_def_module`), not the instance's owning module. The two
/// coincide under a canonical-form-compliant workspace (bare implies
/// local-to-defining-module), but using the defining-module lookup is
/// robust against pre-`validate_canonical_type_names`-wired fixtures
/// that may still carry bare cross-module refs.
fn normalize_type_for_registry(
    t: &Type,
    type_def_module: &BTreeMap<String, String>,
) -> Type {
    match t {
        Type::Con { name, args } => {
            let new_name = if name.contains('.') || is_primitive_type_name(name) {
                name.clone()
            } else if let Some(owner) = type_def_module.get(name) {
                format!("{owner}.{name}")
            } else {
                // Unknown bare non-primitive — leave as-is. Either it is
                // a class-param Type::Var miscoded as a Con (which is a
                // separate well-formedness problem) or a stale ref that
                // downstream diagnostics will catch.
                name.clone()
            };
            Type::Con {
                name: new_name,
                args: args
                    .iter()
                    .map(|a| normalize_type_for_registry(a, type_def_module))
                    .collect(),
            }
        }
        Type::Fn { params, param_modes, ret, ret_mode, effects } => Type::Fn {
            params: params
                .iter()
                .map(|p| normalize_type_for_registry(p, type_def_module))
                .collect(),
            param_modes: param_modes.clone(),
            ret: Box::new(normalize_type_for_registry(ret, type_def_module)),
            ret_mode: *ret_mode,
            effects: effects.clone(),
        },
        Type::Forall { vars, constraints, body } => Type::Forall {
            vars: vars.clone(),
            constraints: constraints
                .iter()
                .map(|c| crate::ast::Constraint {
                    class: c.class.clone(),
                    type_: normalize_type_for_registry(&c.type_, type_def_module),
                })
                .collect(),
            body: Box::new(normalize_type_for_registry(body, type_def_module)),
        },
        Type::Var { name } => Type::Var { name: name.clone() },
    }
}

new_string:

/// `type_def_module`), not the instance's owning module. The two
/// coincide under a canonical-form-compliant workspace (bare implies
/// local-to-caller-module), but using the defining-module lookup is
/// robust against pre-`validate_canonical_type_names`-wired fixtures
/// that may still carry bare cross-module refs.
///
/// ctt.2: bare-name lookups are keyed by `(caller_module, name)`,
/// not by `name` alone. A bare `Foo` written from module M resolves
/// to M's `Foo` only; same-named types in other modules are
/// distinct entries under their own caller-keyed tuple.
fn normalize_type_for_registry(
    caller_module: &str,
    t: &Type,
    type_def_module: &BTreeMap<(String, String), String>,
) -> Type {
    match t {
        Type::Con { name, args } => {
            let new_name = if name.contains('.') || is_primitive_type_name(name) {
                name.clone()
            } else if let Some(owner) =
                type_def_module.get(&(caller_module.to_string(), name.clone()))
            {
                format!("{owner}.{name}")
            } else {
                // Unknown bare non-primitive — leave as-is. Either it is
                // a class-param Type::Var miscoded as a Con (which is a
                // separate well-formedness problem) or a stale ref that
                // downstream diagnostics will catch.
                name.clone()
            };
            Type::Con {
                name: new_name,
                args: args
                    .iter()
                    .map(|a| normalize_type_for_registry(caller_module, a, type_def_module))
                    .collect(),
            }
        }
        Type::Fn { params, param_modes, ret, ret_mode, effects } => Type::Fn {
            params: params
                .iter()
                .map(|p| normalize_type_for_registry(caller_module, p, type_def_module))
                .collect(),
            param_modes: param_modes.clone(),
            ret: Box::new(normalize_type_for_registry(caller_module, ret, type_def_module)),
            ret_mode: *ret_mode,
            effects: effects.clone(),
        },
        Type::Forall { vars, constraints, body } => Type::Forall {
            vars: vars.clone(),
            constraints: constraints
                .iter()
                .map(|c| crate::ast::Constraint {
                    class: c.class.clone(),
                    type_: normalize_type_for_registry(caller_module, &c.type_, type_def_module),
                })
                .collect(),
            body: Box::new(normalize_type_for_registry(caller_module, body, type_def_module)),
        },
        Type::Var { name } => Type::Var { name: name.clone() },
    }
}
  • Step 6: Update the inline test at workspace.rs:2523-2566

Apply Edit 1 to crates/ailang-core/src/workspace.rs (map type + insert key):

old_string:

        let mut type_def_module: BTreeMap<String, String> = BTreeMap::new();
        type_def_module.insert("MyInt".to_string(), "other".to_string());

new_string:

        let mut type_def_module: BTreeMap<(String, String), String> = BTreeMap::new();
        // Caller module is "caller" for this test; the type `MyInt`
        // lives in `other`. Under the tuple key the bare-name lookup
        // resolves only when the caller is "caller".
        type_def_module.insert(
            ("caller".to_string(), "MyInt".to_string()),
            "other".to_string(),
        );

Apply Edit 2 to crates/ailang-core/src/workspace.rs (call site):

old_string:

        let out = normalize_type_for_registry(&input, &type_def_module);

new_string:

        let out = normalize_type_for_registry("caller", &input, &type_def_module);
  • Step 7: Update ailang-check consumer sites

Apply Edit 1 to crates/ailang-check/src/lib.rs:1639-1640 (the exact surrounding context to disambiguate; the call site is the NoInstance check inside check_fn where env: &Env is in scope):

old_string:

                .normalize_type_for_lookup(&r_ty)

new_string:

                .normalize_type_for_lookup(env.current_module.as_str(), &r_ty)

Note: if the literal .normalize_type_for_lookup(&r_ty) appears at multiple sites in lib.rs, the implementer disambiguates using a larger surrounding-context Edit (the spec-recon report identified this as a single site at line 1639-1640 — adjust if recon's line number is stale).

Apply Edit 2 to crates/ailang-check/src/mono.rs:120-121 (the MonoTarget::ClassMethod site inside monomorphise_workspace; defining_module is in scope from the destructured pattern):

old_string:

                ws_owned.registry.normalize_type_for_lookup(type_)

new_string:

                ws_owned.registry.normalize_type_for_lookup(defining_module.as_str(), type_)

Apply Edit 3 to crates/ailang-check/src/mono.rs:623-624 (the collect_mono_targets site; module_name: &str is in scope from the enclosing fn signature):

old_string:

            env.workspace_registry.normalize_type_for_lookup(&r_ty)

new_string:

            env.workspace_registry.normalize_type_for_lookup(module_name, &r_ty)

Note: if env.workspace_registry.normalize_type_for_lookup(&r_ty) matches both the 623-624 site and the 1174-1175 site, use a larger surrounding-context Edit for each. The two enclosing functions (collect_mono_targets and collect_residuals_ordered) both have module_name: &str in scope; the new call form is identical.

Apply Edit 4 to crates/ailang-check/src/mono.rs:1174-1175 (the collect_residuals_ordered site; same shape as Edit 3 — if Edit 3 matched both already via replace_all, this step is a no-op verification rather than a separate edit).

  • Step 8: Build, confirm no compile errors

Run: cargo build --workspace Expected: clean build, no errors. If errors surface, they will most likely be:

  • One of the four ailang-check call sites was missed and still passes the old single-argument form.
  • A test under crates/ other than the ones named in the Files-listing references normalize_type_for_lookup or normalize_type_for_registry and needs the same threading.
  • An ailang-codegen consumer surfaces (recon did not see one, but a compile error is the catch-all).

For any such surface, apply the same caller_module threading pattern and re-run cargo build --workspace.


Task 3: Workspace-wide test gate

Files: (verification only — no edits)

  • Step 1: Run the ctt.2 regression test, confirm GREEN

Run: cargo test -p ailang-core --test ctt2_registry_rekey two_modules_with_same_bare_foo_both_register Expected: PASS. Both (MyC, type_hash) entries land in ws.registry.entries; the test's two assert! calls succeed.

  • Step 2: Run the entire workspace test suite

Run: cargo test --workspace Expected: all tests pass, including:

  • The updated ct1_5a_normalize_recurses_into_forall_constraints (workspace.rs:2523).
  • The new two_modules_with_same_bare_foo_both_register.
  • Every existing test in ailang-core, ailang-check, ailang-codegen, ailang-prose, ailang-surface, ail.

If any existing test fails: the most plausible cause is a fixture under examples/ that legitimately exercises a bare cross-module type reference and depended on the silent-overwrite behaviour. Under the tuple key such a reference now misses the lookup and the corresponding instance falls into the <primitive-or-unknown> coherence path. Inspect the failing fixture; if the failure is the canonical-form-violation it is structurally diagnosing, the fixture is the bug and the test is the win — fix the fixture by qualifying the cross-module type reference.

  • Step 3: Bench scripts are deferred to audit

The audit skill at milestone close runs bench/check.py, bench/compile_check.py, bench/cross_lang.py. ctt.2 makes zero codegen-relevant edits (the registry is workspace-load-time metadata, not runtime), so no bench movement is plausibly attributable to it; the audit confirms.


Acceptance criteria recap (from spec)

  • Registry.type_def_module key is (String, String), value is String. ✓ Task 2 Step 1.
  • Every consumer site provides the calling module at the call site. ✓ Task 2 Steps 2-7.
  • A regression test exercises two-module bare-name collision and confirms both instances resolve correctly. ✓ Task 1 + Task 3 Step 1.
  • cargo test --workspace green. ✓ Task 3 Step 2.
  • Doc-comment at workspace.rs:97-105 carries the new shape's invariant. ✓ Task 2 Step 1.