# mir.2 — `Callee::Static` pre-resolved, codegen stops re-deriving the callee — Implementation Plan

> **Parent spec:** `docs/specs/0060-typed-mir.md` (Iteration decomposition table, mir.2 row)
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement`
> skill to run this plan. Steps use `- [ ]` checkboxes for tracking.

**Goal:** Move static-callee resolution out of codegen and into
`lower_to_mir`: each `Term::App` callee is classified once (mirroring
check's own `synth` ladder) into `Callee::Builtin` / `Callee::Static`
/ `Callee::Indirect`, so codegen reads the resolved callee off MIR and
`is_static_callee` + `type_home_module` are deleted.

**Architecture:** `lower_to_mir` gains a `classify_callee` helper that
reproduces `synth`'s `Term::Var` resolution ladder
(`crates/ailang-check/src/lib.rs:3409-3582`) and reports *which rung
fired* as a `Callee` variant; the `Callee::Static`/`Builtin` carry the
resolved callee's fn-type (`sig`) so codegen's drop path keeps reading
`ret_mode`. Codegen's `MTerm::App` arm matches the variant — `Static`
→ `emit_call` (module pre-resolved), `Builtin` → inline opcode
lowering, `Indirect` → the fn-pointer path — and the two re-derivers
plus the `lower_app ↔ is_static_callee` lockstep pair are struck.

**Tech Stack:** `ailang-mir` (the `Callee` bridge enum),
`ailang-check::lower_to_mir` (producer), `ailang-codegen` (consumer:
`lower_term`/`lower_app`/`drop`), the `lower_to_mir_ty` producer pins.

---

## Design decisions locked before this plan (orchestrator)

These were settled during planning; they are NOT open in the tasks.

1. **`Callee` is a three-variant enum carrying the callee fn-type.**
   The landed shape (`crates/ailang-mir/src/lib.rs:136-139`) is
   `Static { module, fn_name }` + `Indirect(Box<MTerm>)`. mir.2 adds a
   third variant `Builtin { name, sig }` and a `sig: Type` field on
   `Static`. Rationale: (a) operators / str-num builtins (`+`, `not`,
   `str_concat`, …) are lowered inline by name and have no
   `module`/`fn_name` — they need their own variant, not a sentinel
   module; (b) `drop::synth_callee_ret_mode` reads the callee's
   `ret_mode`, today off `Callee::Indirect(inner).ty()` — once a
   resolved callee carries no sub-term, the `sig` is the lossless
   replacement (it is exactly `synth_pure(callee)`, the type the inner
   term would have carried). This is **not** a mir.3 pull-forward: the
   `MArg.mode`/`consume_count` param-side fields stay at their mir.1
   defaults.

2. **Classification mirrors check's `synth`, never copies codegen's
   `is_static_callee` allowlist.** The recon's divergence audit
   confirmed check's builtin set (`crates/ailang-check/src/builtins.rs`
   `install`) and codegen's inline-arm set
   (`crates/ailang-codegen/src/lib.rs:2441-2620`) match exactly, so the
   single-engine rule is mechanically satisfiable: a callee that synth
   resolves through `env.globals` with no owning module is a `Builtin`;
   one with an owner is a `Static`.

3. **`type_home_module` is fully deletable in mir.2.** Its two live
   call sites are `lower_app:2647` (the call path — replaced by
   `Callee::Static`) and `resolve_top_level_fn:3007` (the
   *value*-position fn-reference path). A workspace-wide grep
   confirmed **no program references a type-scoped `T.fn` as a value**
   (every dotted `T.fn` in the corpus is a call head; the only
   value-position hits are doc strings). So `resolve_top_level_fn`'s
   `type_home_module` fallback collapses to the plain
   `prefix.to_string()` module-name fallback with no behaviour change,
   and the spec's "type_home_module grep-clean" acceptance holds as
   written. (The spec's "×3 mirrors" wording over-counts; the live
   surface is the definition + 2 call sites. Acceptance is grep-clean,
   not the literal count.)

**Files this plan creates or modifies:**

- Modify: `crates/ailang-mir/src/lib.rs:135-139` — add `Callee::Builtin`, add `sig` to `Callee::Static`.
- Modify: `crates/ailang-check/src/lower_to_mir.rs:121-129` — `classify_callee` helper + rewritten `Term::App` arm.
- Modify: `crates/ailang-codegen/src/lib.rs` — `MTerm::App` arm (`:1998-2035`), `lower_app`→`lower_builtin` split (`:2428-2711`), delete `is_static_callee` (`:2918-2976`), delete `type_home_module` (`:2897-2911`), `resolve_top_level_fn` fixup (`:3007`).
- Modify: `crates/ailang-codegen/src/drop.rs:510-519` — `synth_callee_ret_mode` reads `sig` for `Static`/`Builtin`.
- Test: `crates/ailang-check/tests/lower_to_mir_ty.rs` — strengthen the #53 pin to assert `Callee::Static`; add a three-way classification pin.
- Modify (docs/ledger): `CLAUDE.md:311` (strike lockstep row), `docs/specs/0060-typed-mir.md:184-202` + mir.2 acceptance, stale codegen comments + `crates/ail/tests/e2e.rs:2751`.

---

## Task 1: Reshape the `Callee` bridge enum

**Files:**
- Modify: `crates/ailang-mir/src/lib.rs:135-139`

- [ ] **Step 1: Add the `Builtin` variant and the `sig` field**

Replace the enum at `crates/ailang-mir/src/lib.rs:135-139`:

```rust
#[derive(Debug, Clone)]
pub enum Callee {
    Static { module: String, fn_name: String },
    Indirect(Box<MTerm>),
}
```

with:

```rust
/// A resolved App callee. `Static`/`Builtin` are filled by mir.2's
/// `lower_to_mir::classify_callee`, which mirrors check's `synth`
/// `Term::Var` ladder; `Indirect` is a genuinely dynamic callee
/// (fn-typed local/param, closure value, or a name shadowed by a
/// local). `sig` is the callee's fn-type — the lossless replacement
/// for the sub-term's `ty()` that codegen's drop path reads for the
/// callee `ret_mode`.
#[derive(Debug, Clone)]
pub enum Callee {
    /// A statically-resolved user/prelude fn: codegen routes it to
    /// `emit_call(module, fn_name, …)` with `module` pre-resolved
    /// (replacing codegen's `type_home_module` ladder).
    Static { module: String, fn_name: String, sig: Type },
    /// An operator / intrinsic builtin (`+`, `not`, `str_concat`, …):
    /// codegen lowers it inline by `name` (opcode selection), never
    /// via `emit_call`.
    Builtin { name: String, sig: Type },
    /// A dynamic callee — lower the boxed term to a fn-pointer.
    Indirect(Box<MTerm>),
}
```

- [ ] **Step 2: Build the leaf crate (partial gate — workspace stays red until Task 3)**

Run: `cargo build -p ailang-mir`
Expected: PASS (`Finished`).

> NOTE: the full workspace will NOT build until Task 3 — adding the
> `Builtin` variant makes the `match callee { Static, Indirect }`
> arms in `ailang-codegen` (`lib.rs:2003`, `drop.rs:511`)
> non-exhaustive. That is expected and closed in Task 3; do not try to
> green the workspace here.

---

## Task 2: Producer — `lower_to_mir` classifies the callee

**Files:**
- Modify: `crates/ailang-check/src/lower_to_mir.rs:121-129` (the `Term::App` arm) + a new helper above `lower_term`

- [ ] **Step 1: Add the `classify_callee` helper**

Insert this helper immediately above `fn lower_term`
(`crates/ailang-check/src/lower_to_mir.rs:110`). It reproduces synth's
`Term::Var` ladder (`crates/ailang-check/src/lib.rs:3409-3582`) and
reports the resolved identity:

```rust
/// The resolved identity of an App callee, mirroring synth's
/// `Term::Var` resolution ladder (`lib.rs:3409-3582`). `lower_term`
/// turns this into the matching `Callee` variant. Resolution uses
/// check's own env (the single engine) — never a copy of codegen's
/// `is_static_callee` allowlist.
enum CalleeClass {
    Builtin { name: String },
    Static { module: String, fn_name: String },
    Indirect,
}

fn classify_callee(ctx: &Ctx, callee: &Term) -> CalleeClass {
    // A non-`Var` callee (lambda, applied expression, …) is dynamic.
    let name = match callee {
        Term::Var { name } => name.clone(),
        _ => return CalleeClass::Indirect,
    };
    // rung 1: shadowed by a local binder → dynamic (synth `lib.rs:3409`).
    if ctx.locals.contains_key(&name) {
        return CalleeClass::Indirect;
    }
    // rung 5: dotted `T.fn` / `Mod.fn` — the TypeDef-first ladder
    // (synth `lib.rs:3557-3582`). `T.fn` resolves to T's home module;
    // `Mod.fn` to the imported module. This replaces codegen's
    // `type_home_module`.
    if name.matches('.').count() == 1 {
        let (prefix, suffix) = name.split_once('.').expect("checked");
        let type_home = ctx
            .env
            .module_types
            .iter()
            .find_map(|(m, types)| types.contains_key(prefix).then(|| m.clone()));
        let target = type_home.or_else(|| ctx.env.imports.get(prefix).cloned());
        return match target {
            Some(module) => CalleeClass::Static { module, fn_name: suffix.to_string() },
            // Unreachable for typechecked input (check would have
            // raised TypeScopedReceiverNotAType); defensive dynamic.
            None => CalleeClass::Indirect,
        };
    }
    // rung 2: same-module global. It is a user fn IFF this module's
    // declared globals carry the name; otherwise it is a builtin —
    // both live in `env.globals` (synth `lib.rs:3416-3425`).
    if ctx.env.globals.contains_key(&name) {
        let is_user_fn = ctx
            .env
            .module_globals
            .get(&ctx.env.current_module)
            .is_some_and(|m| m.contains_key(&name));
        return if is_user_fn {
            CalleeClass::Static { module: ctx.env.current_module.clone(), fn_name: name }
        } else {
            CalleeClass::Builtin { name }
        };
    }
    // rung 3: implicit-import (prelude-fallback) fn (synth `lib.rs:3428-3435`).
    if let Some(module) = ctx.env.imports.values().find_map(|m| {
        ctx.env
            .module_globals
            .get(m)
            .filter(|g| g.contains_key(&name))
            .map(|_| m.clone())
    }) {
        return CalleeClass::Static { module, fn_name: name };
    }
    // Typechecked input always resolves above; defensive dynamic.
    CalleeClass::Indirect
}
```

> Implementer note: confirm `Ctx` exposes `env: &Env` and
> `locals: IndexMap<String, Type>` (`lower_to_mir.rs:21-26`), and that
> `Env` exposes the public fields `globals`, `module_globals`,
> `module_types`, `imports`, `current_module` used above (they are the
> same fields synth reads at `lib.rs:3409-3582`). `is_some_and` is
> stable; if the crate's MSRV rejects it, use
> `.map_or(false, |m| m.contains_key(&name))`. `bool::then` returns
> `Option`; if clippy prefers `then_some`, the closure form is fine
> since the value is a clone.

- [ ] **Step 2: Rewrite the `Term::App` arm to emit the classified callee**

Replace `crates/ailang-check/src/lower_to_mir.rs:121-129`:

```rust
        // callee is Indirect at mir.1 (mir.2 resolves Static).
        Term::App { callee, args, tail } => {
            let m_callee = Callee::Indirect(Box::new(lower_term(ctx, callee)?));
            let m_args = args
                .iter()
                .map(|a| Ok(arg(lower_term(ctx, a)?)))
                .collect::<Result<Vec<_>>>()?;
            MTerm::App { callee: m_callee, args: m_args, tail: *tail, ty }
        }
```

with:

```rust
        // mir.2: classify the callee against check's own resolution
        // ladder and emit the resolved `Callee`. `sig` is the callee's
        // fn-type (mode-preserving via `synth_pure`), carried so
        // codegen's drop path reads the callee `ret_mode`.
        Term::App { callee, args, tail } => {
            let class = classify_callee(ctx, callee);
            let sig = ctx.synth_pure(callee)?;
            let m_callee = match class {
                CalleeClass::Builtin { name } => Callee::Builtin { name, sig },
                CalleeClass::Static { module, fn_name } => {
                    Callee::Static { module, fn_name, sig }
                }
                CalleeClass::Indirect => {
                    Callee::Indirect(Box::new(lower_term(ctx, callee)?))
                }
            };
            let m_args = args
                .iter()
                .map(|a| Ok(arg(lower_term(ctx, a)?)))
                .collect::<Result<Vec<_>>>()?;
            MTerm::App { callee: m_callee, args: m_args, tail: *tail, ty }
        }
```

> Implementer note: `classify_callee(ctx, callee)` takes `&Ctx`
> (immutable) and returns an owned `CalleeClass`, so its borrow ends
> before the `ctx.synth_pure(callee)?` mutable call — no borrow
> conflict. Ensure `Callee` and the (now three) variants are imported
> at the top of `lower_to_mir.rs` (the `use ailang_mir::…` line that
> already brings in `Callee`).

- [ ] **Step 3: Build the producer crate (partial gate)**

Run: `cargo build -p ailang-check`
Expected: PASS (`Finished`).

> The workspace still will not build (codegen consumers are stale) —
> that is Task 3. `cargo build -p ailang-check` does not pull codegen,
> so it is a clean partial gate for the producer.

---

## Task 3: Consumer — codegen reads the resolved callee; delete the re-derivers

**Files:**
- Modify: `crates/ailang-codegen/src/drop.rs:510-519`
- Modify: `crates/ailang-codegen/src/lib.rs:1998-2035` (the `MTerm::App` arm)
- Modify: `crates/ailang-codegen/src/lib.rs:2428-2711` (`lower_app` → `lower_builtin`, delete the module-resolution branches)
- Modify: `crates/ailang-codegen/src/lib.rs:2918-2976` (delete `is_static_callee`)
- Modify: `crates/ailang-codegen/src/lib.rs:2897-2911` (delete `type_home_module`)
- Modify: `crates/ailang-codegen/src/lib.rs:3007` (`resolve_top_level_fn` fallback fixup)

- [ ] **Step 1: `drop::synth_callee_ret_mode` reads `sig` for the resolved variants**

Replace `crates/ailang-codegen/src/drop.rs:510-519`:

```rust
    fn synth_callee_ret_mode(&self, callee: &Callee) -> Option<ParamMode> {
        let cty = match callee {
            Callee::Indirect(inner) => inner.ty(),
            Callee::Static { .. } => return None,
        };
        match cty {
            Type::Fn { ret_mode, .. } => Some(ret_mode),
            _ => None,
        }
    }
```

with:

```rust
    fn synth_callee_ret_mode(&self, callee: &Callee) -> Option<ParamMode> {
        // A resolved callee carries its fn-type as `sig`; an indirect
        // one carries it on the boxed sub-term. Both yield the callee
        // `ret_mode` identically — there is no behaviour change from
        // mir.1b, only a different place the same fn-type is read from.
        let cty = match callee {
            Callee::Indirect(inner) => inner.ty(),
            Callee::Static { sig, .. } | Callee::Builtin { sig, .. } => sig.clone(),
        };
        match cty {
            Type::Fn { ret_mode, .. } => Some(ret_mode),
            _ => None,
        }
    }
```

- [ ] **Step 2: Rewrite the codegen `MTerm::App` arm to match the resolved `Callee`**

Replace `crates/ailang-codegen/src/lib.rs:1998-2035` (the whole
`MTerm::App { callee, args, tail, .. } => { … }` arm):

```rust
            MTerm::App { callee, args, tail, .. } => {
                // callee is `Callee::Indirect(Box<MTerm>)` at mir.1b;
                // the static name, if any, is the inner `MTerm::Var`.
                // (mir.2 replaces this with `Callee::Static` and deletes
                // `is_static_callee`.)
                let callee_mterm: &MTerm = match callee {
                    Callee::Indirect(inner) => inner.as_ref(),
                    Callee::Static { .. } => unreachable!("callee is Indirect until mir.2"),
                };
                // Direct call when the callee is a `Var` referring to a
                // statically-known target (builtin, current-module fn,
                // qualified cross-module fn) AND not shadowed by a local.
                // Otherwise we fall through to the indirect-call path,
                // which lowers the callee to a fn-pointer and looks up
                // its sig in the sidetable.
                if let MTerm::Var { name, .. } = callee_mterm {
                    let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name);
                    if !shadowed && self.is_static_callee(name) {
                        return self.lower_app(name, args, *tail);
                    }
                }
                let (callee_ssa, callee_ty) = self.lower_term(callee_mterm)?;
                if callee_ty != "ptr" {
                    return Err(CodegenError::Internal(format!(
                        "indirect call: callee type must be ptr, got {callee_ty}"
                    )));
                }
                let sig = self
                    .ssa_fn_sigs
                    .get(&callee_ssa)
                    .cloned()
                    .ok_or_else(|| {
                        CodegenError::Internal(format!(
                            "indirect call: no FnSig recorded for `{callee_ssa}`"
                        ))
                    })?;
                self.emit_indirect_call(&callee_ssa, &sig, args, *tail)
            }
```

with:

```rust
            MTerm::App { callee, args, tail, .. } => match callee {
                // mir.2: the callee is pre-resolved in MIR. A builtin
                // is lowered inline by name (opcode selection); a
                // static user/prelude fn routes to `emit_call` with the
                // module already resolved; an indirect callee is
                // lowered to a fn-pointer and called via the sidetable
                // sig. There is no `is_static_callee` walk and no
                // `type_home_module` ladder.
                Callee::Builtin { name, .. } => self.lower_builtin(name, args, *tail),
                Callee::Static { module, fn_name, .. } => {
                    let sig = self
                        .module_user_fns
                        .get(module)
                        .and_then(|m| m.get(fn_name))
                        .cloned()
                        .ok_or_else(|| {
                            CodegenError::Internal(format!(
                                "static call `{module}.{fn_name}`: no FnSig in workspace"
                            ))
                        })?;
                    self.emit_call(module, fn_name, &sig, args, *tail)
                }
                Callee::Indirect(inner) => {
                    let (callee_ssa, callee_ty) = self.lower_term(inner)?;
                    if callee_ty != "ptr" {
                        return Err(CodegenError::Internal(format!(
                            "indirect call: callee type must be ptr, got {callee_ty}"
                        )));
                    }
                    let sig = self
                        .ssa_fn_sigs
                        .get(&callee_ssa)
                        .cloned()
                        .ok_or_else(|| {
                            CodegenError::Internal(format!(
                                "indirect call: no FnSig recorded for `{callee_ssa}`"
                            ))
                        })?;
                    self.emit_indirect_call(&callee_ssa, &sig, args, *tail)
                }
            },
```

- [ ] **Step 3: Split `lower_app` into `lower_builtin` (delete the module-resolution branches)**

`lower_app` (`crates/ailang-codegen/src/lib.rs:2428`) currently holds
two halves: the inline builtin/operator arms (`:2441-2620`) and the
module-resolution + `emit_call` branches (`:2624-2711`). mir.2 keeps
only the builtin half (now driven by `Callee::Builtin`); the
module-resolution half is dead (its sole driver — the `is_static_callee`
path at `:2016` — is gone, and `Callee::Static` routes straight to
`emit_call` in Step 2).

1. Rename the function signature at `:2428` from:

```rust
    fn lower_app(&mut self, name: &str, args: &[MArg], tail: bool) -> Result<(String, String)> {
```

to:

```rust
    /// Lower a `Callee::Builtin` call inline (opcode selection). Only
    /// the operator / str-num builtins reach here; user/prelude fns are
    /// `Callee::Static` and route to `emit_call`. (Pre-mir.2 this was
    /// `lower_app`, which also walked the cross-module / current-module
    /// / prelude resolution ladder — that ladder moved into
    /// `lower_to_mir::classify_callee`.)
    fn lower_builtin(&mut self, name: &str, args: &[MArg], tail: bool) -> Result<(String, String)> {
```

2. Delete the entire module-resolution tail of the function — from the
   `// Cross-module call:` comment at `:2622` through the final
   `Err(CodegenError::Internal(format!("unknown callee: \`{name}\`")))`
   and its closing `}` at `:2708-2710`. The builtin arms above it
   (`:2441` through the last builtin arm, ending just before `:2622`)
   are retained verbatim, followed by a new tail:

```rust
        Err(CodegenError::Internal(format!(
            "lower_builtin: `{name}` is not a builtin (should have been \
             classified as Static or Indirect in lower_to_mir)"
        )))
    }
```

> Implementer note: the retained builtin arms end at the last
> `if name == … { … return Ok(…); }` block before the `// Cross-module
> call:` comment (recon anchor `:2620`). Read the region `:2441-2622`
> and cut exactly at the comment; do not delete any builtin arm. After
> the cut, `lower_builtin` has no remaining reference to
> `type_home_module`, `import_map`-based call resolution, `emit_call`,
> or `module_user_fns` — those all lived in the deleted tail.

- [ ] **Step 4: Delete `is_static_callee`**

Delete the whole function `fn is_static_callee(&self, name: &str) -> bool`
at `crates/ailang-codegen/src/lib.rs:2918-2976` (the definition through
its closing brace). Its only call site was the `:2016` block removed in
Step 2.

- [ ] **Step 5: Delete `type_home_module` and fix `resolve_top_level_fn`**

1. Delete the whole function
   `fn type_home_module(&self, type_name: &str) -> Option<String>` at
   `crates/ailang-codegen/src/lib.rs:2897-2911`.

2. In `resolve_top_level_fn` (`:2984`), replace the dotted-resolution
   `target` match at `:3004-3008`:

```rust
            let target: String = match self.import_map.get(prefix) {
                Some(m) => m.clone(),
                None if self.module_user_fns.contains_key(prefix) => prefix.to_string(),
                None => self.type_home_module(prefix).unwrap_or_else(|| prefix.to_string()),
            };
```

with:

```rust
            // mir.2: `type_home_module` is gone. A type-scoped `T.fn`
            // reference in VALUE position (the only path that used the
            // type-home fallback here) is unreachable in the current
            // corpus — every dotted `T.fn` is a call head, resolved as
            // `Callee::Static` in lower_to_mir. The remaining value-
            // position dotted forms (`Mod.fn`) resolve through
            // `import_map` / the module-name fallback. If a type-scoped
            // fn is ever taken as a first-class value, its home-module
            // resolution moves into MIR at mir.5 (the last re-derivation
            // residue); until then the module-name fallback is correct.
            let target: String = match self.import_map.get(prefix) {
                Some(m) => m.clone(),
                None => prefix.to_string(),
            };
```

> Implementer note: the `None if module_user_fns.contains_key(prefix)`
> arm collapses into the final `None => prefix.to_string()` (both yield
> `prefix.to_string()` for a module-name prefix), so the two-arm form
> above is behaviour-equivalent for every reachable input.

- [ ] **Step 6: Build the whole workspace (the consuming gate)**

Run: `cargo build --workspace`
Expected: PASS (`Finished`). Zero errors. If a `match callee`
non-exhaustiveness error remains, an `if let Callee::Indirect(...)`
site in `escape.rs` / `lambda.rs` is fine (those deliberately skip
non-Indirect callees — see Task 4 Step 4); a *non-exhaustive `match`*
error means a Step-2/Step-1 arm is missing a variant.

---

## Task 4: Pins — assert the resolution comes from MIR, and the guards stay green

**Files:**
- Test: `crates/ailang-check/tests/lower_to_mir_ty.rs` (strengthen #53 pin + add classification pin)

- [ ] **Step 1: Strengthen the #53 producer pin to assert `Callee::Static`**

In `crates/ailang-check/tests/lower_to_mir_ty.rs`, locate
`fn new_over_user_adt_carries_node_types` (`:51`). It already builds a
`MirWorkspace` for the `new_counter_user_adt` fixture and inspects the
`Counter.new` App node's `ty`. Add, after the existing `ty`
assertions on that node, an assertion on the resolved callee. Append
this block inside that test (adjust the node-navigation binding name to
match the existing `MTerm::App { .. }` the test already destructures —
read the test first):

```rust
    // mir.2: the `(new Counter 42)` desugars to `App{callee:
    // Var{"Counter.new"}}`; lower_to_mir must resolve it to a
    // `Callee::Static` whose module is `Counter`'s home (its own
    // module, spelled bare per the own-module-types-stay-bare rule),
    // NOT leave it `Indirect` for codegen's deleted ladder to resolve.
    match callee {
        ailang_mir::Callee::Static { module, fn_name, .. } => {
            assert_eq!(module, "new_counter_user_adt", "Counter.new home module");
            assert_eq!(fn_name, "new", "Counter.new fn_name");
        }
        other => panic!("expected Callee::Static for Counter.new, got {other:?}"),
    }
```

> Implementer note: the test currently destructures the App node to
> reach its `ty`; reuse that same `MTerm::App { callee, .. }` binding
> for the `callee` matched above. If the test reaches the node via a
> helper that returns only the `ty`, extend the navigation to bind the
> whole `MTerm::App` node first. `ailang_mir` is already a dev/test
> dependency of `ailang-check` (the file builds `MirWorkspace`).

- [ ] **Step 2: Add a three-way classification pin**

Add a new test to `crates/ailang-check/tests/lower_to_mir_ty.rs` that
pins all three `Callee` variants from one fixture. Use the existing
fixture-elaboration helper the file already provides (read its name —
`elaborate_fixture` / `body` per recon; reuse it). The fixture: a fn
that (a) calls an arithmetic builtin `(app + i 1)` → `Builtin`, and
(b) calls a bare same-module user fn → `Static`. (An `Indirect` case
needs a fn-typed local; assert it via an existing higher-order example
if the helper supports loading one, otherwise keep this pin to the
Builtin + Static legs and note the Indirect leg is covered by the
existing closure e2e tests.)

```rust
#[test]
fn callee_classification_builtin_and_static() {
    // `helper` is the same fn the sibling tests use to elaborate an
    // inline module source to a MirWorkspace and reach a named def's
    // body. Mirror their call shape exactly.
    let src = r#"
(module classify_pin
  (fn bump
    (type (fn-type (params (con Int)) (ret (con Int))))
    (params n)
    (body (app + n 1)))
  (fn main
    (type (fn-type (params) (ret (con Int))))
    (params)
    (body (app bump 41))))
"#;
    let ws = elaborate_fixture(src);
    let m = ws.modules.get("classify_pin").expect("module");

    // `bump`'s body is `(app + n 1)` → the `+` callee is a Builtin.
    let bump = m.defs.iter().find(|d| d.name == "bump").expect("bump");
    match &bump.body {
        MTerm::App { callee: Callee::Builtin { name, .. }, .. } => {
            assert_eq!(name, "+", "arithmetic op classified as Builtin");
        }
        other => panic!("expected Builtin `+`, got {other:?}"),
    }

    // `main`'s body is `(app bump 41)` → `bump` is a same-module user
    // fn → Static{module: "classify_pin", fn_name: "bump"}.
    let main = m.defs.iter().find(|d| d.name == "main").expect("main");
    match &main.body {
        MTerm::App { callee: Callee::Static { module, fn_name, .. }, .. } => {
            assert_eq!(module, "classify_pin", "own-module callee module");
            assert_eq!(fn_name, "bump", "own-module callee fn_name");
        }
        other => panic!("expected Static `bump`, got {other:?}"),
    }
}
```

> Implementer note: match the exact name and return type of the
> file's fixture helper (`elaborate_fixture` is the recon's name; if it
> differs, use the real one) and its `MirWorkspace` accessor shape
> (`ws.modules` is a `BTreeMap<String, MirModule>` per
> `ailang-mir/src/lib.rs:16-19`; `MirDef.body` is the `MTerm` per
> `:57`). Import `MTerm` and `Callee` from `ailang_mir` at the top of
> the test file if not already imported. If `bump`'s single-expression
> body is wrapped (e.g. the helper returns the def's `body` already, or
> the body is the outer `MTerm` directly), navigate to the `App` node
> accordingly — read a sibling test for the exact shape.

- [ ] **Step 3: Run the producer pins**

Run: `cargo test -p ailang-check --test lower_to_mir_ty`
Expected: PASS — including `new_over_user_adt_carries_node_types` (now
asserting `Callee::Static`) and `callee_classification_builtin_and_static`.

- [ ] **Step 4: Verify the escape / lambda Indirect-only sites still hold**

The escape/capture walkers match `if let Callee::Indirect(inner)`
(`crates/ailang-codegen/src/escape.rs:141`, `:241`, `:460`;
`crates/ailang-codegen/src/lambda.rs:401`). A `Static`/`Builtin` callee
has no sub-term to walk and contributes no free var or capture (it
names a global, not a local), so skipping it is correct. Confirm by
reading each site that the skipped branch carried no
free-var/escape/capture fact through the inner `Var` (it did not — a
bare fn-name `Var` is a global reference). No code change; this is a
read-and-confirm step.

Run: `cargo test --workspace`
Expected: PASS — the full suite green, including the acceptance
witnesses: `user_adt_new_mono_pin` (#53), `raw_buf_new_type_arg_pin`
(#51, still builds), the RC-stats drop pins (drop `ret_mode` still
read), and `show_print_e2e` (the cross-module case mir.1b surfaced).
`loop_recur_str_binder_no_leak_pin` stays `#[ignore]` (#49 lifts at
mir.4).

> Note: run the FULL workspace suite, not just `e2e` — mir.1b's case 6
> was caught only by `show_print_e2e`, a separate file. `cargo test
> --workspace` is the real acceptance gate.

---

## Task 5: Strike the lockstep pair, update the spec, clean stale references

**Files:**
- Modify: `CLAUDE.md:311`
- Modify: `docs/specs/0060-typed-mir.md:184-202` + the mir.2 acceptance line
- Modify: stale codegen comments + `crates/ail/tests/e2e.rs:2751`

- [ ] **Step 1: Strike the `lower_app ↔ is_static_callee` lockstep row**

In `CLAUDE.md`, delete exactly the table row at `:311` (the
`lower_app` arms ↔ `is_static_callee` pair). Leave the
`Pattern::Lit::*` row (`:310`) and the `INTERCEPTS ↔ (intrinsic)` row
(`:312`) intact. The struck row is the one beginning
`` | `lower_app` arms in `crates/ailang-codegen/src/lib.rs::lower_app` ``.

Run: `rg -n 'is_static_callee' CLAUDE.md`
Expected: no matches (the symbol appears only in that row).

- [ ] **Step 2: Update spec 0060's `Callee` shape and mir.2 acceptance**

In `docs/specs/0060-typed-mir.md`, update the `Callee` line in the
`## Concrete code shapes` block (`:199`) from:

```rust
pub enum Callee  { Static { module: String, fn_name: String }, Indirect(Box<MTerm>) }  // (2)
```

to:

```rust
pub enum Callee  { Static { module: String, fn_name: String, sig: Type }, Builtin { name: String, sig: Type }, Indirect(Box<MTerm>) }  // (2) — sig carries the callee fn-type for drop ret_mode; Builtin for inline-lowered operators/intrinsics
```

And in the mir.2 row of the Iteration-decomposition table (`:309`),
leave the "Deletes / Adds / Acceptance" columns as written (they are
still accurate: `Callee::Static` pre-resolved; deletes `type_home_module`
+ `is_static_callee`; strikes the lockstep pair; #53 green via MIR) —
append one sentence to the spec's prose under that table noting the
mir.2 refinements discovered in planning:

```markdown
> mir.2 refinement (discovered in planning, `docs/plans/0116-mir.2-callee-static.md`):
> `Callee` gains a `Builtin { name, sig }` variant (operators / str-num
> intrinsics have no `module`/`fn_name`) and a `sig: Type` on each
> resolved variant (codegen's drop path reads the callee `ret_mode` off
> it — the lossless replacement for the pre-mir.2 `Indirect(inner).ty()`
> read; this is NOT a mir.3 pull-forward, the `MArg` param-modes stay at
> mir.1 defaults). `type_home_module` is fully deletable: its
> value-position consumer (`resolve_top_level_fn`) is unreachable for
> type-scoped names in the current corpus, so the acceptance
> "type_home_module grep-clean" holds.
```

- [ ] **Step 3: Clean stale `is_static_callee` / `type_home_module` comment references**

Update the comments that name the now-deleted symbols so the grep-clean
gate is honest. Read each and rewrite the reference (do not delete
load-bearing comment prose; just drop/replace the stale symbol name):

- `crates/ailang-codegen/src/lib.rs` comments referencing `is_static_callee` (recon anchors `:65`, `:2002`, `:2644`, `:2645`, `:2696`, `:2983`) and `type_home_module` (`:2896`-area doc) — most live in regions deleted by Task 3; for any surviving comment (e.g. the `resolve_top_level_fn` doc at `:2982-2983` mentioning "`is_static_callee` filters them earlier"), reword to drop the dead symbol (e.g. "operators are not first-class values and never reach here as a `Callee::Static`/`Builtin`").
- `crates/ail/tests/e2e.rs:2751` — the prose doc comment naming "is_static_callee whitelist"; reword to reference the `Callee` classification in `lower_to_mir`.

- [ ] **Step 4: Final grep-clean acceptance gate**

Run: `rg -n 'is_static_callee|type_home_module' crates/ailang-codegen/ crates/ail/`
Expected: no matches. (Both symbols — definitions, calls, and comment
references — are gone from the codegen crate and the CLI test crate.)

Run: `cargo test --workspace`
Expected: PASS — re-confirm the full suite is green after the comment
edits (doc-only, so this is a fast re-gate).

---

## Self-review (planner, inline)

1. **Spec coverage.** mir.2 row of spec 0060: `Callee::Static`
   pre-resolved (Tasks 1-2), deletes `type_home_module` +
   `is_static_callee` (Task 3 Steps 4-5), strikes the lockstep pair
   (Task 5 Step 1), #53 green via MIR (Task 4 Step 1). All covered.
2. **Placeholder scan.** No "TBD/TODO/similar to/implement later".
   Every code step carries exact before→after bytes. The two
   implementer-judgement points (the test's existing node-navigation
   binding; the `lower_builtin` cut boundary) are framed as
   read-the-region-then-apply, with the exact anchor lines — not open
   decisions.
3. **Type/name consistency.** `Callee::{Static,Builtin,Indirect}`,
   `lower_builtin`, `classify_callee`, `CalleeClass`, `synth_callee_ret_mode`,
   `module_user_fns`, `emit_call`, `lower_to_mir.rs`/`drop.rs`/`lib.rs`
   paths consistent across tasks.
4. **Step granularity.** Each step is one edit or one command. Task 3
   is the largest (the atomic consumer switch — like mir.1b's switch,
   it cannot be split without a non-compiling intermediate), but its
   steps are individually small and ordered so each is a 2-5 min edit;
   the single workspace build gate sits at its end.
5. **No commit steps.** None present.
6. **Pin/replacement substring contiguity.** The grep gates (Task 5
   Steps 1, 4) assert on `is_static_callee` / `type_home_module` /
   `is_static_callee in CLAUDE.md` — all single tokens that appear
   contiguously; no soft-wrap split. The `#53` pin asserts on
   `module`/`fn_name` string equality, not a substring of a wrapped
   body.
7. **Compile-gate vs deferred-caller ordering.** Tasks 1 and 2 carry
   *explicitly partial* gates (`-p ailang-mir`, `-p ailang-check`) with
   a written note that the workspace is red until Task 3; Task 3's
   gate is the first workspace-wide `cargo build`, and it threads every
   `Callee` consumer (App arm, drop, escape/lambda are Indirect-only
   and need no thread) in that same task. No caller is deferred past a
   "0 errors" workspace gate.
8. **Verification-command filter strings resolve.** `cargo test -p
   ailang-check --test lower_to_mir_ty` targets a real file
   (`crates/ailang-check/tests/lower_to_mir_ty.rs`, recon-confirmed);
   the named tests `new_over_user_adt_carries_node_types` and the new
   `callee_classification_builtin_and_static` are pinned by exact name;
   the workspace gates use the unfiltered suite. The `rg` gates assert
   on real present symbols expected to reach zero.
9. **Parse-the-bytes gate.** The two inlined `.ail` fixtures (the
   `classify_pin` module in Task 4 Step 2 and the spec-block edits) are
   surface-language bodies — see the parse-trace appended below.

### Parse-trace (self-review #9 / Step-7 gate)

The plan inlines one new `.ail` program (the `classify_pin` fixture in
Task 4 Step 2). The other `.ail` snippets quoted (the #51/#53/#49
fixtures in the spec) are existing committed files, already parsed.
The `classify_pin` source is the bytes to gate:

```
(module classify_pin
  (fn bump (type (fn-type (params (con Int)) (ret (con Int)))) (params n)
    (body (app + n 1)))
  (fn main (type (fn-type (params) (ret (con Int)))) (params)
    (body (app bump 41))))
```

Run (the project's surface parser, per the profile's `spec_validation`
parser for the `ail` fence — `ail parse`):
`ail parse <tmpfile>.ail`
Expected: exit 0 (parses to a Form-B module with two `Def::Fn`s). The
implementer runs this as the first action of Task 4 Step 2 before
wiring the fixture into the test; a non-zero exit is a fixture defect
to fix in the plan, not downstream.

> Planner note: `bump` returns `(con Int)` with no effects and `main`
> calls it purely — `(app + n 1)` and `(app bump 41)` are both
> well-formed App forms. `+` is the arithmetic builtin (rung 2,
> owner=None → `Builtin`); `bump` is a same-module `Def::Fn` (rung 2,
> owner=Some → `Static`). The fixture exercises exactly the two legs
> the pin asserts.

---

## Handoff

Plan sits unstaged at `docs/plans/0116-mir.2-callee-static.md`. Hand to
`implement` (standard mode, all five tasks). The orchestrator commits
the plan when handing it forward and the iteration diff at iter close.
