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

> **Parent spec:** `docs/specs/2026-05-12-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-123` —
  `Registry::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-928` —
  `normalize_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-1640` —
  `normalize_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-121` —
  `ws_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-624` —
  `env.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-1175` —
  `env.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`

```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`

```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`

```rust
//! 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.
