# Unique Binder Names per Fn — Design Spec

**Date:** 2026-05-30
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude

## Goal

Close the last open leg of the owned-heap drop-leak cluster #43: the
`UniquenessTable` shadow-name collapse. The uniqueness side-table is
keyed by `(def_name, binder_name)`
(`crates/ailang-check/src/uniqueness.rs:99`). When a fn shadows a
binder name — `(let buf (new …) (let buf (set buf …) … (get buf)))` —
every shadow records under the same key; the last `record_binder` to
fire (the **outermost** binding, which pops last) overwrites the
inner ones. Codegen's scope-close drop gates then read the collapsed
`consume_count` for the *innermost* binding, misjudge whether it is
still owned, and suppress its drop. The owned heap slab leaks.

The fix gives every binder a name that is unique within its fn, so the
`(def, name)` key is injective again. This is done by alpha-renaming
shadowing binders during desugaring — the existing lowering pass that
already mints fresh unique names for its own synthetic binders
(`$mp_N`, `$lr_N`). No consumer of the uniqueness table changes: they
all key by name, and the name is now an injective per-fn identity.

### Scope: what this is and is not

This resolves a **compiler-internal naming collision**. It is *not*
the deeper, separate problem of giving AST nodes a persistent identity
with provenance back to the pre-desugar (authored) form — that
back-reference is certain to be needed eventually but is **not** a
requirement here, and conflating the two is a category error. The
authored `.ail` (the canonical, content-addressed, hashed form) is
untouched: renaming happens only on the post-desugar internal tree,
which is already a lowered form whose binder names desugar already
treats as tags.

## Architecture

The post-desugar AST is an internal lowering, not the authored
artifact. Desugar already (a) threads a lexical `scope` through every
term (`desugar_term(t, scope)`), and (b) owns a collision-free
fresh-name generator (`Desugarer::fresh`, used for `$mp_N` match
temporaries and `$lr_N` lifted letrecs). The fix extends (a) to detect
a binder whose name is already bound in the enclosing `scope` and, on
such a shadow, allocate a fresh unique name for it, recording the
original→fresh mapping in the scope handed to that binder's body. The
one structural addition is that `Term::Var` — today a verbatim
`t.clone()` — must rewrite its name through the active rename map so
references resolve to the renamed binder.

The rename is **on shadow only** and **uniform across binder kinds**.
Non-shadowing binders keep their authored name, so every existing
fixture's desugared output is byte-identical; only an actual shadow is
renamed. The scope check is identical at every binder-introduction
site, so a single mechanism covers `Term::Let`, flat-`Match`
pattern-binders, `Term::Lam` params, and `Term::Loop` binders. Of
those, only `Term::Let` and flat-`Match` pattern-binders currently
reach a uniqueness-gated drop site (see Components); covering the
others is free under the same scope check and forecloses the collision
class for any future gate.

Emitted LLVM IR is unaffected: codegen names heap values by fresh SSA
(`%vN`), never by the source binder name (the binder name is only an
internal lookup key into `self.locals` / `moved_slots` / the
uniqueness table). Renaming an internal binder therefore changes no IR
bytes — only which uniqueness entry the lookup resolves to, which is
the entire point.

## Concrete code shapes

### North-star — the LLM-natural programs that must stop leaking

These are shipped (or completeness-securing) fixtures. They are the
empirical evidence: an LLM author *did* write the shadow-rebind idiom
(it is the milestone fixture), and the fix makes it correct.

**Let-shadow (the shipped #43/#42 symptom, `examples/raw_buf_int.ail`):**

```ail
(module raw_buf_int
  (fn main
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body
      (app print
        (let buf (new RawBuf (con Int) 3)
          (let buf (app RawBuf.set buf 0 10)
            (let buf (app RawBuf.set buf 1 20)
              (let buf (app RawBuf.set buf 2 30)
                (app + (app RawBuf.get buf 0)
                  (app + (app RawBuf.get buf 1)
                    (app RawBuf.get buf 2)))))))))))
```

Prints `60`; under `AILANG_RC_STATS` must reach `live == 0` (today
`live == 1`).

**Flat-pattern-shadow (completeness, `examples/flat_pat_shadow_leak.ail`):**
a flat-match pattern-binder `x` shadowing an outer `let x`; the inner
`x` (a heap `Box` payload) is borrow-only and must drop at arm close,
but the collision suppresses it. Paired with
`examples/flat_pat_shadow_control.ail` (inner binder alpha-renamed to
`y`); the shadow must reach the control's live count.

### How desugar rewrites the north-star (worked, Let case)

Input is the four-`buf` body above. Output binds each shadow to a
fresh name and rewrites references in its body; values are desugared
in the *outer* scope (before the binder is installed), so each
`RawBuf.set` still refers to the previous binding:

```text
(let buf   (new RawBuf (con Int) 3)
  (let buf$1 (app RawBuf.set buf   0 10)
    (let buf$2 (app RawBuf.set buf$1 1 20)
      (let buf$3 (app RawBuf.set buf$2 2 30)
        (app + (app RawBuf.get buf$3 0)
          (app + (app RawBuf.get buf$3 1)
            (app RawBuf.get buf$3 2)))))))
```

Keys become `(main,buf)`, `(main,buf$1)`, `(main,buf$2)`,
`(main,buf$3)` — all distinct. The three outer bindings are each
consumed once by the next `set`'s `own` param (`consume_count == 1`,
correctly not dropped: `set` is in-place, same slab); the innermost
`buf$3` is only borrowed by the three `get`s (`consume_count == 0`)
and is dropped once at scope close. Exactly one drop fires, balancing
the one slab alloc → `live == 0`.

### Implementation shape (secondary)

**`Term::Var` — rewrite through the active rename map.** Before:

```rust
Term::Lit { .. } | Term::Var { .. } => t.clone(),
```

After (Var split out; rename map carried alongside `scope`):

```rust
Term::Lit { .. } => t.clone(),
Term::Var { name } => Term::Var {
    name: scope.resolved_name(name),   // renamed binding, or `name` verbatim
},
```

**Each binder-introduction site — rename on shadow.** Before
(`Term::Let`):

```rust
Term::Let { name, value, body } => {
    let v = self.desugar_term(value, scope);
    let mut inner = scope.clone();
    inner.insert(name.clone(), ScopeEntry::LetBound);
    let b = self.desugar_term(body, &inner);
    Term::Let { name: name.clone(), value: Box::new(v), body: Box::new(b) }
}
```

After:

```rust
Term::Let { name, value, body } => {
    let v = self.desugar_term(value, scope);          // outer scope: refs to prior binding
    let mut inner = scope.clone();
    let bound = inner.bind_renaming_on_shadow(name, &mut self.fresh_binder);
    let b = self.desugar_term(body, &inner);
    Term::Let { name: bound, value: Box::new(v), body: Box::new(b) }
}
```

`bind_renaming_on_shadow` returns `name` verbatim when it is free in
`scope`, or a fresh unique name (recorded as `name → fresh` in `inner`
for `resolved_name`) when it shadows. The same call wraps the
`Match`-arm pattern-binder insertion (`desugar_term`'s `Term::Match`
arm loop), the `Term::Lam` param loop, and the `Term::Loop` binder
loop. Flat-match arms (returned verbatim by `desugar_match` today)
must carry the renamed pattern so the renamed name reaches codegen's
arm-close gate.

The fresh-name generator must avoid all in-scope names and the
existing `$mp_N` / `$lr_N` spaces; a per-fn `<name>$<n>` scheme
checked against the scope satisfies this and is visually traceable to
its origin binder.

## Components

| Component | File | Change |
|---|---|---|
| Rename-on-shadow at binder sites | `crates/ailang-core/src/desugar.rs` | detect shadow against `scope`, mint fresh name, record mapping; applied at `Let`, flat-`Match` arm, `Lam` params, `Loop` binders |
| `Term::Var` resolution | `crates/ailang-core/src/desugar.rs` | rewrite name through the active rename map |
| Fresh-binder name scheme | `crates/ailang-core/src/desugar.rs` | per-fn `<name>$<n>`, collision-checked against scope + `$mp`/`$lr` |

**No change** to the uniqueness pass, codegen drop gates, or
`linearity`. They key by `(def, name)`; the fix makes `name`
injective, so their existing lookups become correct without edits.
The three uniqueness-gated drop sites and their exposure:

| Drop gate | Site | Keyed by | Shadows? |
|---|---|---|---|
| fn `own`-param dec at return | `codegen/src/lib.rs:1447` | `(def, pname)` | never (params unique per signature) |
| `Term::Let` scope-close drop | `codegen/src/lib.rs:1792` | `(def, name)` | **yes — fixed here** |
| `Match` arm-close pattern dec | `codegen/src/match_lower.rs:795` | `(def, bname)` | **yes — fixed here** |

## Data flow

`parse (.ail → JSON-AST, hashed)` → **`desugar_module` (renames
shadows here)** → `lift_letrecs` → `monomorphise` → `check`
(uniqueness inference reads renamed names) / `codegen` (drop gates
read renamed names). The rename lives entirely between parse and the
two consumers; both consumers see the same renamed tree, so they
cannot disagree about which binding a key denotes. The pre-desugar
hashed form never carries a rename, so module identity and all
hash-pin tests are unaffected.

## Error handling

No new diagnostics. Renaming is a total, infallible rewrite: every
binder either keeps its name or receives a guaranteed-fresh one.
The fresh-name loop is bounded by the in-scope set (finite per fn) and
cannot fail to terminate (it increments until free, exactly as
`Desugarer::fresh` already does). Programs that were ill-typed before
desugar are rejected upstream as today; the rename does not run on
un-parsed input.

## Testing strategy

RED-first; all RED fixtures already exist in the working tree:

- **Let-shadow (3, shipped):** `raw_buf_{int,float,bool}_shadow_rebind_drop_balances_rc_stats` — assert `live == 0`. RED today (`live == 1`).
- **Flat-pattern-shadow (1, differential):** `flat_pat_shadow_binder_does_not_leak_more_than_alpha_renamed` — assert the shadow fixture's `live` equals its alpha-renamed control's. RED today (`5` vs `3`). Differential by design, to isolate the shadow-collapse drop from unrelated baseline drop gaps (Box outer cell, implicit-consume of the outer binder) that are out of #43's scope.

Regression guards (must stay green):

- **No-op on non-shadowing fixtures:** the full existing suite must
  stay byte-identical — every fixture without a shadow desugars
  unchanged. This is the evidence that the rename is on-shadow-only.
- **Reference rewrite correctness:** a shadow whose body reads the
  renamed binder (the `get buf$3` path) must still compute the right
  value — covered by the stdout assertions on the four fixtures
  (`60`, and the pattern fixtures' printed sums).
- **Desugar unit tests:** any existing `desugar_module` test on a
  shadowing input updates to the renamed output; non-shadowing inputs
  are untouched.

## Acceptance criteria

1. All four RED tests above are GREEN.
2. The full workspace test suite is otherwise unchanged (no
   regression; no non-shadowing fixture's output drifts).
3. Emitted IR for the shipped fixtures is unchanged except for the
   added drop call(s) — SSA names do not depend on binder names.
4. `examples/raw_buf_int.ail` reaches `live == 0` under
   `AILANG_RC_STATS`, closing #43.
5. No change to `uniqueness.rs`, the codegen drop gates, or
   `linearity.rs`; the `UniquenessTable` doc-comment
   (`uniqueness.rs:90-98`), currently wrong about pop-order
   ("last = innermost") and about every fixture having unique binder
   names, is corrected to state that desugar guarantees per-fn name
   uniqueness.
```
