# Kernel extension mechanics — Design Spec

**Date:** 2026-05-28
**Status:** Draft — awaiting user spec review
**Authors:** Brummel (orchestrator) + Claude
**Reference:** `design/models/0007-kernel-extensions.md` for the
architectural whitepaper. This spec covers the *first* of three
milestones implementing the kernel-extensions design: the
language-level mechanisms (this milestone), the `raw-buf` base
extension (Rust intercepts emitting LLVM IR — the only tier that
needs implementation outside AILang itself), and the `series`
library extension (pure AILang code wrapping RawBuf). The two
follow-up milestones will be brainstormed after this one closes.

## Goal

Ship the four language-level mechanisms that the kernel-extensions
design requires, with zero domain-specific code (no Series, no
Matrix, nothing per-extension). After this milestone, the language
supports:

1. Type-scoped namespacing — `<TypeName>.<member>` resolves to the
   member's def in the type's home module.
2. The `new` term construct — `(new T arg1 arg2 ...)` calls the
   `new` def in T's home module.
3. Kernel-tier modules — `Module.kernel: bool` flag with
   auto-import of all top-level defs and types.
4. `param-in` — closed-set type-parameter restriction on TypeDefs.

The milestone closes when all four mechanisms are on main, their
diagnostics are formulated and tested, the schema-drift pin
ratifies the new tags, and a stub kernel-tier module exists
purely as a test fixture to exercise the mechanism end-to-end
(no domain operations, just a marker type with a `new`).

## Architecture

The four mechanisms live in different layers:

- **Schema layer** (`ailang-core`): `Module.kernel`, `TypeDef.param-in`,
  `Term::New`. Three additive fields/variants, each `skip_serializing_if`
  so existing fixtures hash-stable.
- **Surface layer** (`ailang-surface`): new keyword `new`,
  new module header attribute `(kernel)`. Print symmetry preserved.
- **Resolver / Checker layer** (`ailang-check`): type-scoped member
  resolution, `param-in` enforcement, `Term::New` arg-kind checking.
- **Workspace-load layer** (`ailang-core::workspace`): kernel-tier
  auto-import.

Codegen layer is *not* touched in this milestone. `Term::New` with
a kernel-tier consumer would need codegen support, but we ship the
mechanisms first and exercise them with non-codegen tests
(`ail check` clean is the bar). Codegen integration for kernel
extensions happens in the Series milestone, where the first real
intercept-registry use case forces the codegen-side mechanism.

A stub kernel module (`crates/ailang-kernel-stub/`) ships as part
of this milestone purely to ratify the mechanism. It contains:

- One TypeDef with `param-in` set to a small allowed set.
- One `new` def with a deliberately constrained signature.
- The `kernel: true` flag.

The stub never reaches codegen — `ail check` is the verification
endpoint. When the Series milestone follows, the stub may be
retired or repositioned as a regression fixture.

## Concrete code shapes

### Iteration prep.1 — Type-scoped namespacing

**Worked author example (this is what the LLM writes after prep.1
lands):**

```
(module consumer
  (import std_maybe)

  (fn classify
    (type (fn-type
      (params (con Int))
      (ret (con Maybe (con Int)))))
    (params x)
    (body
      (if (app eq x 0)
        (term-ctor Maybe Nothing)
        (term-ctor Maybe Just x))))

  (fn main
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body
      (app print (app Maybe.from_maybe 99 (app classify 7))))))
```

Key shifts from today's surface:

- `(con Maybe (con Int))` — bare type, no `std_maybe.` prefix in
  the type position. (Today this is `(con std_maybe.Maybe (con Int))`.)
- `(app Maybe.from_maybe 99 ...)` — type-scoped op access. (Today:
  `(app std_maybe.from_maybe 99 ...)`.)
- `(term-ctor Maybe Just x)` — bare type name in `term-ctor`. (Today:
  `(term-ctor std_maybe.Maybe Just x)`.)
- The `(import std_maybe)` declaration stays — it brings the
  `Maybe` type into the consumer's type namespace. (Future
  alternative `(import Maybe from std_maybe)` is out of scope.)

**Implementation shape (before → after).**

`ailang-check`'s resolver: today, when seeing `Term::Var { name: "std_maybe.from_maybe" }`,
it splits at `.`, looks up `std_maybe` as a module, finds
`from_maybe` in that module's defs. After: the same call site
with `Term::Var { name: "Maybe.from_maybe" }` first tries to
resolve `Maybe` as a type (look up TypeDef in the workspace),
then if a TypeDef is found, locates the home module, then looks
up `from_maybe` in the home module's defs. If the receiver is
neither a module nor a type, emit `TypeScopedReceiverNotAType`.
If the receiver is a known type but the member is not in its
home module, emit `TypeScopedMemberNotFound`.

The module-as-receiver path remains supported for free-standing
defs (not associated with a single type) but is no longer the
canonical form for type-associated operations.

**Type-position resolution.** `Type::Con { name: "Maybe", args }`
in a signature: today the workspace-load rejects bare cross-module
type refs via `BareCrossModuleTypeRef`. After prep.1, the resolver
first checks whether `Maybe` is in scope (imported by name); if
yes, it resolves to the imported type. The `BareCrossModuleTypeRef`
diagnostic narrows to: "type name not in scope (no matching import,
no kernel-tier provider)". Kernel-tier types are in scope without
imports (prep.3).

**Canonical form decision.** Type-scoped form is canonical for
type-associated operations. Module-scoped form is retired for
this use case; it remains valid only for module-free defs (defs
that do not take a specific type as receiver) and as a workspace
collision disambiguator (no current collisions exist).

**Realisation mechanism — workspace pre-pass.** What the
"Implementation shape" paragraphs describe at the resolver level
is *the user-facing semantics*. The actual realisation in code is
a workspace-wide normalisation step `prepare_workspace_for_check`,
shared by `check_workspace` and `monomorphise_workspace`, that
desugars every module and then rewrites every bare cross-module
`Type::Con.name` and `Term::Ctor.type_name` to its qualified
`<home>.<Type>` form. This is symmetric to the pre-existing
`qualify_local_types` helper, which already qualifies bare
type-refs on the *owner* side; the new pre-pass extends the same
qualification to the *consumer* side. Downstream passes (checker
synth, mono, codegen) therefore see qualified Types regardless of
how the author wrote them. The TypeDef-first ladder still lives
in `synth`'s `Term::Var` arm, because the `<TypeName>.<member>`
form is term-position-only — `Maybe.from_maybe` is a Var, not a
type expression, and the pre-pass does not rewrite Var names.
The two new diagnostics (`TypeScopedReceiverNotAType`,
`TypeScopedMemberNotFound`) fire from the resolver as planned.
The pre-pass design generalises naturally to later iterations:
prep.2's `Term::New.type_name` falls under the same `type_name`
rewrite when the pre-pass walks the `Term` tree, and prep.3's
kernel-tier modules' TypeDefs enter the workspace-wide TypeDef
map automatically (no pre-pass change needed for kernel-tier).

**Blast radius.** Per `grep -rE 'std_(maybe|pair|list|either)\.' examples/ crates/`:

- **`.ail` example fixtures** (12 with cross-module refs, plus the
  three defining modules with no refs): `ct_2_bare_cross_module.ail`,
  `ct_3b_bad_qualified_known_module.ail`, `nested_pat.ail`,
  `std_either_demo.ail`, `std_either_list.ail`,
  `std_either_list_demo.ail`, `std_list.ail`, `std_list_demo.ail`,
  `std_list_more_demo.ail`, `std_list_stress.ail`,
  `std_maybe_demo.ail`, `std_pair_demo.ail`. Each rewritten from
  `std_X.Y` to `Y` (type-scoped) where Y is type-associated. The
  three defining modules `std_either.ail`, `std_maybe.ail`,
  `std_pair.ail` contain no cross-module references and need no
  body changes (intra-module refs are bare today already).
  `ct_2_bare_cross_module.ail`'s role flips semantically: it stays
  RED (the unimported bare type case still fires
  `BareCrossModuleTypeRef` under the narrowed rule), but the
  diagnostic *message* it asserts is the new one.
- **No corresponding `.ail.json` fixtures.** None of the 15
  spec-named `.ail` files have a sibling `.ail.json` round-trip
  pin in the tree.
- **Source-side reference** in `crates/ailang-{check,codegen,surface}/src/`
  and `crates/ail/tests/e2e.rs` (7 files): doc-comments and
  test-helper code mentioning `std_X.Y` vocabulary update to
  type-scoped vocabulary. Production type-resolution logic is the
  new code. The specific sites are enumerated in the prep.1 plan.
- **CLI diagnostic renderer + assertion update**:
  `crates/ail/src/main.rs:1249-1278` (the `BareCrossModuleTypeRef`
  and `BadCrossModuleTypeRef` translation arms) and
  `crates/ail/tests/ct1_check_cli.rs:154` (an assertion on the old
  "ail migrate-canonical-types" hint string) move with the
  diagnostic-message narrowing.

**No hash-pin or schema-drift work in prep.1.** The hash-pin audit
across every test crate (per the `hash-pin blast-radius audit`
lesson from `schema-camelcase-fix`) is mandatory and was performed
during prep.1's plan recon. Result: `crates/ailang-core/tests/hash_pin.rs`
pins `sum.ail`, `list.ail`, `ordering_match.ail`,
`test_22b1_dup_a.ail`, `test_22b1_dup_classmod.ail` — none of which
contain `std_X.Y` references; `crates/ailang-surface/tests/prelude_module_hash_pin.rs`
pins `prelude.ail` — also no `std_X.Y` references. **No pin refresh
applies to prep.1.** The `prelude_module_hash_pin.rs` refresh
happens in prep.3 when `kernel: true` lands on the prelude module.
Schema-drift pins in `crates/ailang-core/tests/design_schema_drift.rs`
also do not apply: type-scoped resolution is a checker change with
no new JSON tags, no new enum variants, no AST shape changes. The
drift pins for this milestone land in prep.2 (new `t: "new"` tag,
new `NewArg` JSON) and prep.3 (new `kernel`, `param-in` schema
attrs).

**Integration with existing mechanisms.**

- `BareCrossModuleTypeRef` / `BadCrossModuleTypeRef` diagnostics
  are repurposed: they fire when a type name is not in scope by
  any path (import, kernel-tier, type-scoped). The class of
  errors they detect narrows, the underlying user-facing message
  becomes more helpful (it can suggest the import or the
  type-scoped form).
- Module-scoped access remains the path for free-standing fns
  (e.g. a hypothetical `std_math.factorial` that doesn't have a
  receiver type). No retirement of the module-scope mechanism
  itself.
- Class method dispatch (see [method-dispatch](../../design/contracts/0016-method-dispatch.md))
  is orthogonal. `(app show x)` continues to type-dispatch via the
  Show instance; this is not type-scoped namespacing.
- `term-ctor` and `pat-ctor` continue to use bare ctor names
  inside the scrutinee — `(term-ctor Maybe Just x)` works
  because `Maybe` is now in scope via type-scoped resolution.

### Iteration prep.2 — `new` term construct

**Worked author example (after prep.2; uses a non-kernel test
type to exercise the construct):**

```
(module new_demo
  (data Counter
    (ctor MkCounter (con Int)))

  (fn new
    (doc "Build a Counter initialised to the given value.")
    (type (fn-type (params (con Int)) (ret (con Counter))))
    (params n)
    (body (term-ctor Counter MkCounter n)))

  (fn main
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body
      (let c (new Counter 42)
        (match c
          (case (pat-ctor MkCounter x) (app print x)))))))
```

The `(new Counter 42)` desugars to: look up `Counter`'s home
module (this very module), find the `new` def, call it with `42`.

For a future kernel-tier consumer with a `new` taking a Type arg:

```
; (Future Series, exercised in milestone 2.)
(let s (new Series (con Float) 3)
  ...)
```

Here the first arg `(con Float)` is parsed as a Type (per the
`NewArg::Type` discriminator), the second `3` as a Term.

**Implementation shape.**

New AST variant in `ailang-core/src/ast.rs`:

```rust
pub enum Term {
    // ...
    New {
        type_name: TypeName,
        args: Vec<NewArg>,
    },
}

pub enum NewArg {
    Type(Type),
    Value(Term),
}
```

Serialised JSON:

```jsonc
{ "t": "new",
  "type": "Series",
  "args": [
    { "kind": "type",  "value": { ... type expr ... } },
    { "kind": "value", "value": { ... term expr ... } }
  ]
}
```

`type_name` carries no module qualifier — it is resolved via
type-scoped lookup at check time (depends on prep.1).

**Form-A surface.** Lex/Parse adds `new` as a keyword. Production:

```
⟨new⟩    ::= '(' 'new' ⟨TypeName⟩ ⟨NewArg⟩+ ')'
⟨NewArg⟩ ::= ⟨Type⟩  ; matches if token sequence parses as a Type production
           | ⟨Term⟩  ; otherwise
```

The disambiguation between Type and Term args is by syntactic
form: a Type starts with `(con …)`, `(fn-type …)`, `(borrow …)`,
`(own …)`, etc. — the existing Type-production keywords. A Term
in arg position is anything else.

**Checker.** When elaborating `Term::New { type_name, args }`:

1. Look up `type_name` via type-scoped resolution → home module.
2. Find `new` def in that home module. If missing, emit
   `NewTypeNotConstructible`.
3. Check arg count against `new`'s signature.
4. For each arg position: the signature's param kind (Type or
   Value) must match the `NewArg` kind. Mismatch emits
   `NewArgKindMismatch`.
5. If `new`'s signature includes a `forall` over a type variable
   instantiated by a Type-positional arg, bind that variable to
   the supplied type. Apply `param-in` restriction (prep.3) if
   declared.

**Canonical form decision.** `new` is the canonical construction
form when construction is *functional* (calls a function). It
does not replace `term-ctor`, which remains canonical for
named-data-ctor construction in ADTs.

**Blast radius.** Small. No existing fixture uses a `new` keyword
or pattern, since the keyword does not exist today. The schema
additions are new variants and new JSON tags; existing fixtures
emit unchanged bytes (no `t: "new"` appears anywhere today).
A new in-source test in `ailang-check/src/lib.rs`'s
`#[cfg(test)]` module exercises the resolution path on the
`new_demo` example fixture above.

**Integration with existing mechanisms.**

- `term-ctor` continues to construct ADT values via named data
  ctors. The two constructs answer different design needs (see
  whitepaper).
- The `new` keyword does not collide with any existing identifier
  (no def named `new` exists in current workspace per `grep`).
  If a user-defined module ever wanted to call something `new`,
  that name is now reserved as a Form-A keyword — they have to
  pick a different name. Pre-production stage, no breakage risk.
- Type-scoped resolution (prep.1) is the dependency: `Term::New`
  uses it to find the home module of `type_name`.

### Iteration prep.3 — Kernel-tier modules + `param-in`

This iteration ships two coupled mechanisms in one atomic step,
because the `param-in` restriction is the first checker behavior
that depends on the workspace knowing about kernel-tier modules
(the stub kernel module is the test fixture for both).

**Worked author example — kernel-tier stub module (this is the
test fixture, not user-facing code):**

```
; crates/ailang-kernel-stub/module.ail.json (or programmatic form)
; The stub module exists only to exercise the mechanism — both
; `kernel: true` auto-import and `param-in` enforcement.
(module kernel_stub (kernel)

  (type StubT (vars a)
    (param-in (a Int Float))
    (ctors (Stub a)))

  (fn new
    (doc "Construct StubT<Int> — exercises Term::New end-to-end.")
    (type (fn-type (params (con Int)) (ret (con StubT (con Int)))))
    (params x)
    (body (term-ctor StubT Stub x))))
```

**Worked consumer (no `(import kernel_stub)` needed — kernel-tier):**

```
(module stub_consumer
  ; No import line. kernel_stub is auto-imported.

  (fn main
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body
      (let s (new StubT 42)
        (match s
          (case (pat-ctor Stub x) (app print x)))))))
```

The `(new StubT 42)` form names the unapplied type constructor
`StubT`; the resolver looks up `StubT`'s home module (`kernel_stub`),
finds `new` there, types the call against `new`'s signature
`(Int) -> StubT Int`. The result type `StubT Int` triggers a
`param-in` check on the type-arg `Int` — `Int ∈ {Int, Float}` so
it passes. A pattern match on `(pat-ctor Stub x)` extracts and
prints the wrapped Int.

**Worked rejection — `param-in` violation:**

```
(fn bad
  (type (fn-type
    (params (con StubT (con Str)))   ; Str not in {Int, Float}
    (ret (con Unit))))
  (params s)
  (body unit))
; expected: ParamNotInRestrictedSet on `(con StubT (con Str))`
```

**Implementation shape.**

Schema additions in `ailang-core/src/ast.rs`:

```rust
pub struct Module {
    // ...
    #[serde(default, skip_serializing_if = "is_false")]
    pub kernel: bool,
}

pub struct TypeDef {
    // ...
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty",
            rename = "param-in")]
    pub param_in: BTreeMap<String, BTreeSet<TypeName>>,
}
```

Both `skip_serializing_if` so existing TypeDefs and Modules hash-
identical.

**Form-A surface.** Module header gains `(kernel)` attribute:

```
(module foo (kernel) ...)
```

TypeDef body gains optional `(param-in (var type1 type2 ...))` block:

```
(type T (vars a)
  (param-in (a Int Float))
  (ctors))
```

**Workspace-load.** Today, the loader hardcodes a single auto-
injected module: `crates/ailang-surface/src/loader.rs:98-108`
unconditionally calls `parse_prelude()` and threads the literal
`&["prelude"]` slice into `ailang_core::workspace::build_workspace`
as the implicit-imports list. The reservation of `prelude` as
an auto-injected name is documented at `workspace.rs:308-311`,
and the build contract at `workspace.rs:467`. This is the
mechanism that makes the prelude's 12 free fns (`ne`, `lt`,
`le`, `gt`, `ge`, `print`, `float_eq`...`float_ge`) callable bare
in every consumer module today (ratified by
`crates/ail/tests/prelude_free_fns.rs`).

prep.3 *generalises* this single-name auto-injection into a
flag-driven multi-module mechanism. After prep.3:

- `workspace::build_workspace`'s implicit-imports parameter is
  derived from "all modules in the workspace with `kernel: true`",
  not from a hardcoded literal.
- `loader.rs`'s `parse_prelude()` injection remains (prelude is a
  built-in module — it has no on-disk manifest in user
  workspaces and must be injected from the compiler's own
  resources), but the *naming* of `prelude` as the implicit
  import is no longer hardcoded; it follows from prelude's
  `kernel: true` flag.
- Other kernel-tier modules (e.g. the stub crate, future Series
  crate) are loaded through the normal workspace discovery and
  picked up by the same `kernel: true` filter.

The name-resolution precedence rule (explicit imports beat
auto-import) is the existing behaviour, retained.

**Checker.** When resolving `Type::Con { name, args }`:

1. After standard TypeDef lookup, fetch `param_in` from the
   TypeDef.
2. For each entry `(var, allowed_set)`:
   - Locate the positional `args[i]` corresponding to `var` (by
     name lookup in TypeDef's `vars`).
   - Walk `args[i]` to its outermost type-name (peel through
     mode wrappers).
   - If not a primitive type name in `allowed_set`, emit
     `ParamNotInRestrictedSet { type_name, var, found, allowed }`.

The check is *generic*: nothing in the checker mentions Series
or any specific extension type. The restriction is data-driven
from the TypeDef.

**Canonical form decision.** `param-in` is the canonical form for
closed-set type-parameter restrictions. Marker classes (a class
with no methods, used solely for type-tagging) are not
introduced as a parallel mechanism.

**Blast radius.**

- The `prelude` module gains `kernel: true`. **This is a code-
  path migration, not a behaviour change.** Prelude has 12 free
  fns today (`examples/prelude.ail:85-148`: `ne`, `lt`, `le`,
  `gt`, `ge`, `print`, `float_eq`, `float_ne`, `float_lt`,
  `float_le`, `float_gt`, `float_ge`) plus its three classes.
  All 12 free fns are already callable bare in consumer modules
  via the hardcoded auto-injection at `loader.rs:98-108`. After
  prep.3, the same 12 fns remain callable bare — but the
  reachability path is the generic `kernel: true` filter, not
  the hardcoded `&["prelude"]` literal. Consumer-observable
  behaviour: identical. Code-path: rewritten. The hash-pin
  refresh in `crates/ailang-surface/tests/prelude_module_hash_pin.rs`
  is required because the canonical-JSON of the prelude module
  now contains `"kernel": true`, which changes the module hash;
  the test file gets the new hash plus an Honesty-Rule
  provenance comment quoting prep.3.
- `crates/ailang-surface/src/loader.rs` and
  `crates/ailang-core/src/workspace.rs` lose the hardcoded
  prelude-specific paths; they gain the flag-driven equivalent.
  Detailed sites: `loader.rs:98-108`, `workspace.rs:308-311,
  467, 2655`. The `WorkspaceLoadError::ReservedModuleName`
  diagnostic (currently fires when a user workspace contains a
  module named `prelude`) is repositioned: any module named the
  same as an auto-injected built-in kernel module is reserved,
  not specifically `prelude`. For this milestone the list is
  still just `prelude` + the new stub.
- The new `crates/ailang-kernel-stub/` crate is created with
  its `module.ail.json` and the build wiring to feed it into
  the workspace load.
- The schema-drift pin in `crates/ailang-core/tests/design_schema_drift.rs`
  gains entries for `kernel`, `param-in`, and a basic stub
  module round-trip.
- `crates/ail/tests/prelude_free_fns.rs` continues to pass
  unchanged — that is the explicit regression-protection that
  the prelude code-path migration is behaviour-equivalent.
- Workspace-load tests under `crates/ailang-core/tests/` gain
  a new fixture exercising auto-import of the stub kernel
  module from a consumer with no `(import ...)` declaration.

**Integration with existing mechanisms.**

- The prelude module's role in the workspace loader is unified
  with the kernel-tier mechanism. The hardcoded prelude-name
  paths today exist concretely at `loader.rs:98-108` (the
  `parse_prelude()` injection + `&["prelude"]` literal) and
  `workspace.rs:308-311, 467, 2655` (reservation + contract
  docs). They are migrated to read `module.kernel` from each
  loaded module's schema and build the implicit-imports list
  from the resulting set. Single named mechanism for
  "module is auto-imported"; no module name is hardcoded as
  special.
- Class constraints (existing) and `param-in` (new) coexist —
  see the whitepaper's "marker class" discussion.
- `Term::New` (prep.2) uses `param-in` via the standard checker
  path — when `new`'s return type is `T<a>` with `a` restricted,
  the type-arg supplied at the new-site is validated.
- The existing primitive-instance codegen intercept
  (`try_emit_primitive_instance_body` in `ailang-codegen`) is
  *not* touched in this milestone. Its migration to a plugin
  registry happens in the Series milestone, where there is a
  real second consumer for the registry. Single-consumer
  registries are premature mechanism.

## Components

| Component | Crate | What changes |
|-----------|-------|-------------|
| AST schema | `ailang-core` | `Module.kernel`, `TypeDef.param-in`, `Term::New`, `NewArg` |
| Form-A surface | `ailang-surface` | `new` keyword, `(kernel)` module attr, `(param-in ...)` typedef attr |
| Workspace-load | `ailang-core::workspace` | Kernel-tier auto-import scope |
| Resolver | `ailang-check` | Type-scoped member resolution, `Term::New` arg-kind check, `param-in` enforcement |
| Drift pin | `ailang-core/tests/design_schema_drift.rs` | New schema tags ratified |
| Hash pins | `ailang-core/tests/hash_pin.rs`, `ailang-surface/tests/prelude_module_hash_pin.rs` | Refreshed for the rewrites |
| Std fixture migration | `examples/*.ail` (~14 files) | `std_X.Y` → type-scoped form |
| Stub kernel crate | `crates/ailang-kernel-stub/` | New, minimal — exercises the mechanism |

## Data flow

A `.ail` consumer module that uses a kernel-tier type:

1. **Workspace load.** Each module's manifest is read. Kernel-tier
   modules are collected; their type names and def names enter
   the auto-import scope.
2. **Parse.** The consumer's `(con Series ...)` (or `(con StubT ...)`
   in this milestone's stub) parses to `Type::Con { name: "Series" }`.
   `(new Series ...)` parses to `Term::New { type_name: "Series", args: [...] }`.
   `(app Series.at s i)` parses to `Term::App { callee: Term::Var { name: "Series.at" }, args }`.
3. **Resolve.** Type-scoped resolver looks up `Series` in the
   workspace TypeDef registry (auto-imported via kernel-tier).
   `Series.at` resolves to the `at` def in Series's home module
   (the stub or, in milestone 2, the Series crate).
4. **Type-check.** `param-in` enforcement runs on `Type::Con {
   name: "Series", args }` — element type must be in the allowed
   set. `Term::New` arg-kind check runs on the args. Standard
   type-check otherwise.
5. **Done at `ail check`.** Codegen is out of scope this milestone.

## Error handling

New diagnostics introduced this milestone (all in `ailang-check`):

| Diagnostic | Trigger | Iteration |
|------------|---------|-----------|
| `TypeScopedMemberNotFound` | `T.x` where T is a type but `x` not in T's home module | prep.1 |
| `TypeScopedReceiverNotAType` | `X.y` where X is neither a known type nor a known module | prep.1 |
| `NewTypeNotConstructible` | `(new T args)` where T has no `new` def in its home module | prep.2 |
| `NewArgKindMismatch` | `(new T args)` where an arg is Type-positional where Value expected (or vice versa) | prep.2 |
| `ParamNotInRestrictedSet` | `Type::Con { name, args }` where `args[i]` violates the TypeDef's `param-in` for the corresponding var | prep.3 |

`BareCrossModuleTypeRef` and `BadCrossModuleTypeRef` (pre-existing)
are repurposed in prep.1: they now fire when a type name resolves
through neither type-scoped lookup, nor an explicit import, nor a
kernel-tier auto-import. The semantic class of error remains "type
name not resolvable", but the suggested fix in the message
changes.

## Testing strategy

**Layer 1 — Schema drift** (`crates/ailang-core/tests/design_schema_drift.rs`):
- `module_kernel_flag_round_trips` — pins `Module.kernel = true`
  serialises and deserialises with bit-identical bytes; `kernel:
  false` is omitted from output.
- `typedef_param_in_round_trips` — pins `TypeDef.param_in` map
  serialises with the `param-in` key (kebab); empty map omitted.
- `term_new_round_trips` — pins `Term::New { type_name, args }`
  serialises with `t: "new"`, each arg with `kind: "type"|"value"`.

**Layer 2 — Round-trip surface** (`crates/ailang-surface/tests/round_trip.rs`):
- Fixture for `(kernel)` in module header.
- Fixture for `(new T ...)` with mixed-kind args.
- Fixture for `(param-in (var t1 t2))` in TypeDef.
- All fixtures round-trip Form-A → JSON → Form-A bit-identical.

**Layer 3 — Checker** (`crates/ailang-check/src/*.rs` in-source tests):
- `type_scoped_member_resolves` — `Maybe.from_maybe` resolves to
  `std_maybe.from_maybe`'s def.
- `type_scoped_member_not_found` — `Maybe.bogus` emits diagnostic.
- `type_scoped_receiver_not_a_type` — `NotAType.x` emits diagnostic.
- `new_resolves_via_type_scope` — `(new Counter 42)` finds local
  `new` def.
- `new_type_not_constructible` — `(new IntList ...)` (no `new` def
  in IntList's home module) emits diagnostic.
- `new_arg_kind_mismatch_value_where_type` — `(new Series 3 (con Float))`
  emits diagnostic.
- `param_in_accepts_allowed_type` — `(con StubT (con Int))`
  type-checks.
- `param_in_rejects_disallowed_type` — `(con StubT (con Str))`
  emits `ParamNotInRestrictedSet`.

**Layer 4 — Workspace load** (`crates/ailang-core/tests/workspace_kernel.rs`,
new):
- Kernel-tier module's types are visible to a consumer without
  any `(import ...)` declaration.
- Two kernel-tier modules with non-colliding names co-load.
- Explicit import overrides auto-import (precedence test).

**Layer 5 — End-to-end via `ail check`** (`crates/ail/tests/e2e.rs`):
- The stub kernel module + a stub consumer checks clean.
- The `param-in` violation example produces the expected
  diagnostic.

**Layer 6 — Migrated std-library examples**:
- All migrated `.ail` examples (std_maybe_demo, std_list_demo,
  etc.) check clean. Their `.ail.json` round-trip pins refreshed.

## Acceptance criteria

Milestone closes when:

1. All four mechanisms (type-scoped namespacing, `Term::New`,
   kernel-tier modules + auto-import, `param-in`) are implemented
   and merged to main.
2. All five new diagnostics fire on their RED fixtures and do
   not fire on green code.
3. The schema-drift pin ratifies `kernel`, `param-in`, `new`,
   `NewArg` JSON tags.
4. The round-trip test exercises all new surface forms.
5. The stub kernel crate's module checks clean and is auto-
   imported by a consumer module with no explicit import.
6. All 14-ish migrated `.ail` examples in `examples/` check
   clean and round-trip to byte-identical Form-A.
7. Hash pins refreshed in both `crates/ailang-core/tests/hash_pin.rs`
   and `crates/ailang-surface/tests/prelude_module_hash_pin.rs`,
   each with an Honesty-Rule provenance comment.
8. The whitepaper `design/models/0007-kernel-extensions.md` is
   updated: sections describing prep.1, prep.2, prep.3 transition
   from forward-looking ("will resolve…") to present-state
   ("resolves…") where appropriate; STATUS header updated to
   "Mechanisms milestone closed YYYY-MM-DD; Series milestone
   pending."
9. The `design/INDEX.md` ledger entry's "design accepted
   2026-05-28; impl in progress" annotation is updated to reflect
   the milestone close.
10. `bench/check.py` clean — no performance regression on the
    existing benches (this milestone touches resolver / parser /
    schema, not codegen or runtime; regression is unexpected
    but bench is part of the close-out per audit discipline).

## Out of scope (explicit)

- The `RawBuf` base extension — kernel-tier module with Rust
  codegen intercepts emitting LLVM IR for `new`/`get`/`set`/`size`,
  element-type restriction via `param-in`. Future Gitea milestone
  `raw-buf`.
- The `Series` library extension — kernel-tier `.ail` module
  defining the Series ADT and its operations (push/at/len/
  total_count) in AILang itself, on top of RawBuf. Future Gitea
  milestone `series`.
- Migration of `try_emit_primitive_instance_body` into a plugin
  intercept registry. Happens in the `raw-buf` milestone when
  the first base-extension consumer exists.
- LSP / MCP integration for `ail describe Series`. Out of scope
  for all three milestones; future axis.
- Higher-kinded `param-in` (the restriction is currently
  flat — type variable to set of named types; future variants
  might restrict to "any type satisfying some structural
  predicate", but that is not specified here).
- Type-scoped access on user types ≠ home-module defs (e.g.
  cross-module-extension methods). Out of scope; the mechanism
  is strictly "type T's home module's defs are accessible as
  T.x".
- An auto-import precedence override syntax (the explicit-import
  wins behaviour is implementation; no new author syntax is added
  for it).
- Record-element support in RawBuf (the struct-of-arrays layout
  for `Series Record`). The architecture accommodates it (see
  whitepaper § "Forward axis: SoA for records") but it is a
  later RawBuf-internal change that does not touch Series's
  AILang code; out of scope for the three named milestones.

## Iteration scope

The plan that follows this spec carves the work into three
iterations:

- **prep.1** — Type-scoped namespacing. Resolver change + 12
  example rewrites + diagnostic-message narrowing + ct1_check_cli
  assertion update. No hash-pin or drift-test work (audit cleared
  during plan recon; pins live in prep.2/3).
- **prep.2** — `Term::New`. AST + surface + checker arg-kind
  enforcement.
- **prep.3** — Kernel-tier modules + `param-in`. Schema +
  workspace-load + checker enforcement + stub crate.

Each iteration is independently shippable; main is green at every
iteration boundary.
