# Fieldtest — kernel-extension-mechanics — 2026-05-28

**Status:** Draft — awaiting orchestrator triage
**Author:** fieldtester (dispatched by fieldtest skill)

## Scope

The kernel-extension-mechanics milestone (commits `b586999`, `078c39a`,
`9339279`, tidy `9d2e752`) ships four language-level mechanisms for
plugin-style extension types: type-scoped namespacing
(`TypeName.member`), the `(new T args)` term construct, kernel-tier
modules with `(kernel)` auto-import flag, and `(param-in (a T1 T2 ...))`
closed-set type-parameter restriction. The milestone touches the
schema, surface parser/printer, checker, and workspace loader.
Codegen for `Term::New` is explicitly out of scope (deferred to
raw-buf). The stub crate `ailang-kernel-stub` ships as an
auto-injected kernel-tier module purely as a ratifying fixture.

## Examples

### `examples/fieldtest/kem_1_list_running_sum.ail` — type-scoped namespacing cross-module
- Builds a `List<Int>` via `term-ctor`, prints its length via
  `(app List.length xs)` and its sum via `(app List.fold_left add 0 xs)`.
  Imports `std_list` explicitly; bare `List`/`Int` in type slots.
- Why fits: this is the spec's prep.1 canonical-form example over a
  different home module and a function (fold_left) that takes a
  lambda arg — a shape an LLM author naturally produces.
- Outcome: `ail check` clean (`ok (48 symbols across 5 modules)`);
  `ail run` prints `5\n15\n`. Required two local symlinks
  (`std_list.ail`, `std_maybe.ail`) in `examples/fieldtest/`
  because the loader resolves cross-module imports against the
  entry file's directory — see Finding F2.

### `examples/fieldtest/kem_2_counter_new.ail` — `(new T args)` term construct
- The spec's literal prep.2 worked example (a local Counter ADT
  with a `new` def and `(new Counter 42)` consumer).
- Outcome: `ail check` clean (`ok (31 symbols across 3 modules)`).
  `ail run` fails with the explicit codegen-deferral diagnostic
  `internal: Term::New cannot be synthed at codegen — must be
  desugared first; codegen support lands in the raw-buf milestone`.
  Per spec § Architecture this is the explicit out-of-scope of the
  milestone — recorded as a `working` finding (Finding F6).

### `examples/fieldtest/kem_2b_min_repro.ail` — minimal repro for the same-module type-scoped-call bug
- Same Counter shape minus the `new` def: one `value` projector,
  one consumer site that calls `(app Counter.value c)` from
  within Counter's home module.
- Why fits: surfaced while extending kem_2 with a `value`
  projector — turned out to be a clean repro of an axis-1 corner
  the spec didn't carve out.
- Outcome: `ail check` fails with
  `[type-mismatch] main: type mismatch: expected
  kem_2b_min_repro.Counter, got Counter`. Switching the call to
  bare `(app value c)` makes the program check clean — see Finding F1.

### `examples/fieldtest/kem_3_stub_consumer.ail` — kernel-tier auto-import in type position
- A consumer that uses `(con StubT (con Int))` in a fn type
  signature without `(import kernel_stub)`. The stub kernel module
  is auto-injected by the workspace loader (`ail workspace` lists
  it) and per spec § "Iteration prep.3" "the module's types are
  accessible bare in type position".
- Outcome: `ail check` fails with
  `[unknown-module] unwrap_int: unknown module prefix
  `kernel_stub` in qualified reference` (twice). Adding an explicit
  `(import kernel_stub)` fails earlier with `[module-not-found]
  expected at <sibling>/kernel_stub.ail.json` because the stub has
  no on-disk file — see Finding F3.

### `examples/fieldtest/kem_4_paramin_box_green.ail` — param-in restriction, accepting path
- A user-defined `NumBox (vars a) (param-in (a Int Float))` ADT,
  instantiated with `(con NumBox (con Int))`. Verifies the
  param-in check is permissive when the type-arg is in the
  allowed set.
- Outcome: `ail check` clean; `ail run` prints `17\n`. See
  Finding F7.

### `examples/fieldtest/kem_4_paramin_box_red.ail` — param-in restriction, must-fail path
- Same `NumBox` with `param-in (a Int Float)`, instantiated with
  `(con NumBox (con Str))`. Expected diagnostic per spec table:
  `ParamNotInRestrictedSet`.
- Outcome: `ail check` fails with
  `[param-not-in-restricted-set] unwrap_str: type-arg `Str` is
  not in the restricted set for type-variable `a` of `NumBox``.
  Diagnostic is precise — names the offending type, the
  type-variable, and the TypeDef that owns the restriction. See
  Finding F8.

## Findings

### [bug] F1 — Type-scoped call to a same-module def is rejected with a phantom-qualified type-mismatch
- **Example:** `kem_2b_min_repro.ail` (minimal); also originally
  surfaced in `kem_2_counter_new.ail` while drafting.
- **What happened (verbatim):**
  ```
  $ ail check examples/fieldtest/kem_2b_min_repro.ail
  error: [type-mismatch] main: type mismatch:
      expected kem_2b_min_repro.Counter, got Counter
  ```
  Switching the call site from `(app Counter.value c)` to bare
  `(app value c)` makes the same program check clean.
- **Why it is a bug:** Per spec § "Iteration prep.1 — Type-scoped
  namespacing" → "Canonical form decision": "Type-scoped form is
  the canonical form for type-associated operations". The spec
  does NOT carve out same-module calls as forbidden / different.
  An LLM author who's just written `value` next to `data Counter`
  in the same file will naturally reach for `Counter.value` from
  inside main — the same way they spell every cross-module call
  per Axis 1. The current resolver appears to produce a
  qualified `<this-module>.Counter` for the return type of
  `value` reached through `Counter.value`, but compares against
  an unqualified `Counter` produced by the caller's local-scope
  `value` lookup. The diagnostic itself is misleading — the two
  types are the same, just written differently.
- **Repro:** `ail check examples/fieldtest/kem_2b_min_repro.ail`.
- **Recommended downstream action:** `debug` — write a RED test
  that pins the same-module type-scoped-call expects-clean
  behaviour, then `implement` mini-mode fixes the resolver to
  treat `<this-module>.<Type>` and bare `<Type>` as one type.

### [bug] F3 — Kernel-tier auto-import does not register the module as a valid qualifier prefix
- **Example:** `kem_3_stub_consumer.ail`.
- **What happened (verbatim):**
  ```
  $ ail check examples/fieldtest/kem_3_stub_consumer.ail
  error: [unknown-module] unwrap_int: unknown module prefix
      `kernel_stub` in qualified reference
  error: [unknown-module] main: unknown module prefix
      `kernel_stub` in qualified reference
  ```
  `ail workspace examples/fieldtest/kem_3_stub_consumer.ail` lists
  `kernel_stub` as a loaded module with 1 def. Adding an explicit
  `(import kernel_stub)` fails earlier with
  `[module-not-found] expected at
  /home/brummel/dev/ailang/examples/fieldtest/kernel_stub.ail.json`
  — the stub has no on-disk manifest, it is built into the
  compiler.
- **Why it is a bug:** Per spec § "Iteration prep.3 — Kernel-tier
  modules + param-in" the kernel-tier `Module.kernel: true` flag
  promises: "The module's top-level defs are accessible bare in
  every other module of the workspace, with no `(import ...)`
  declaration required. The module's types are accessible bare in
  type position." Prelude's free fns (`print`, `lt`, ...) do
  satisfy that promise — `prelude_free_fns.rs` ratifies it. But
  the stub kernel module's TypeDef `StubT`, referenced bare in a
  type slot, is qualified by the pre-pass to `kernel_stub.StubT`,
  and the resolver then rejects `kernel_stub` as an unknown module
  prefix. The auto-import mechanism is asymmetric: it scopes
  the names but does not register the kernel module as a
  resolvable qualifier. Two layers (the pre-pass that qualifies
  bare type names, and the resolver that validates qualified
  module prefixes) disagree on whether kernel-tier modules count.
  The shipped kernel_stub TypeDef appears effectively unreachable
  from any consumer.
- **Repro:** `ail check examples/fieldtest/kem_3_stub_consumer.ail`.
- **Recommended downstream action:** `debug` — write a RED test
  that a consumer using `(con StubT (con Int))` (or `(con
  kernel_stub.StubT (con Int))` explicitly) checks clean; then
  `implement` mini-mode either teaches the resolver to recognise
  every kernel-tier module as an implicit qualifier, or teaches
  the workspace pre-pass to leave bare references to kernel-tier
  types un-qualified.

### [bug] F4 — Spec's prep.3 worked consumer cannot compile against the actual shipped stub
- **Example:** observed via probe (see § Examples kem_3 narrative);
  not a standalone fixture because F3 already blocks the type
  signature.
- **What happened (verbatim):**
  ```
  $ cat /tmp/stub_consumer.ail
  (module stub_consumer
    (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)))))))
  $ ail check /tmp/stub_consumer.ail
  error: [new-type-not-constructible] main: type `StubT` is not
      constructible: home module `kernel_stub` declares no `new` def
  ```
- **Why it is a bug:** Spec § "Iteration prep.3" → "Worked author
  example — kernel-tier stub module" specifies the stub as a
  TypeDef PLUS a `(fn new ...)` def, and the immediately following
  "Worked consumer" exercises `(new StubT 42)`. The shipped stub
  (per `ail describe --workspace … StubT`) has only the TypeDef
  with one ctor `Stub` — no `new` fn. The implementation diverged
  from the spec's stub design; the consumer-side worked example
  the spec promises no longer compiles. The diagnostic the
  resolver emits is excellent (precise, names the home module,
  names the missing def) — that part is `working` and is
  separately recorded as F5.
- **Repro:** see narrative.
- **Recommended downstream action:** `ratify` — either add the
  `new` def to the stub (the spec's design) and refresh the
  STUB_AIL constant + drift pin, or amend the design model and
  spec to say "the stub has only a TypeDef; consumers must use
  `term-ctor`" (which is also a coherent reading because the
  stub's purpose is to exercise auto-import + param-in, not
  `Term::New`). Either reading restores spec/ship coherence; the
  current state has a drift.

### [working] F5 — `NewTypeNotConstructible` diagnostic is precise
- **Example:** observed via the F4 probe.
- **What happened:** The diagnostic
  `[new-type-not-constructible] main: type `StubT` is not
  constructible: home module `kernel_stub` declares no `new` def`
  names the offending type, the home module, and the missing def
  in one sentence. An LLM author reading this routes correctly to
  one of two natural next actions: add a `new` def, or use a
  different construction path. Worth recording as a win: prep.2's
  diagnostic is doing its job.
- **Recommended downstream action:** carry-on.

### [working] F6 — Term::New codegen-deferral diagnostic names the future milestone
- **Example:** `kem_2_counter_new.ail` via `ail run`.
- **What happened:**
  ```
  Error: module `kem_2_counter_new`: def `main`: internal:
      Term::New cannot be synthed at codegen — must be
      desugared first; codegen support lands in the raw-buf
      milestone
  ```
- **Why a win:** The spec explicitly carves codegen out of scope
  for this milestone. The error message explicitly names the
  future milestone (`raw-buf`) where codegen support will land.
  An LLM author sees the message and routes correctly: "this is
  expected at this stage, the path forward is the raw-buf
  milestone" — no guessing, no false leads. The contrast with the
  phantom-qualified mismatch (F1) is sharp: F6 is what a precise
  out-of-scope diagnostic looks like.
- **Recommended downstream action:** carry-on.

### [working] F7 — param-in accepts allowed type silently and end-to-end
- **Example:** `kem_4_paramin_box_green.ail`.
- **What happened:** `(con NumBox (con Int))` where
  `NumBox`'s `param-in` is `(a Int Float)` checks clean; the
  program also builds and runs (`ail run` prints `17`). The
  param-in mechanism is data-driven from the TypeDef as spec
  promises — nothing in the checker mentions `NumBox` specifically,
  same enforcement path that fires `ParamNotInRestrictedSet` on
  the RED case. A user TypeDef gets the same treatment as the
  built-in stub, confirming the generic data-driven story.
- **Recommended downstream action:** carry-on.

### [working] F8 — ParamNotInRestrictedSet diagnostic is precise
- **Example:** `kem_4_paramin_box_red.ail`.
- **What happened:** Diagnostic
  `[param-not-in-restricted-set] unwrap_str: type-arg `Str` is
  not in the restricted set for type-variable `a` of `NumBox``
  names the type-arg, the type-variable, and the TypeDef. An LLM
  author reading this routes correctly to either (a) change the
  type-arg to one in the allowed set or (b) extend the
  TypeDef's `param-in`. The message does NOT name the allowed
  set explicitly — that would make it a one-line full-context
  diagnostic; recorded as a potential future polish in F9.
- **Recommended downstream action:** carry-on.

### [friction] F2 — Loader's sibling-only module resolution makes the fieldtest dir awkward
- **Example:** `kem_1_list_running_sum.ail` and its symlinked
  `std_list.ail` / `std_maybe.ail` in `examples/fieldtest/`.
- **What happened (verbatim):**
  ```
  $ ail check examples/fieldtest/kem_1_list_running_sum.ail
  error: [module-not-found] module `std_list` not found
      (expected at /home/brummel/dev/ailang/examples/fieldtest/std_list.ail.json)
  ```
  The same consumer module sitting in `examples/` checks clean;
  inside `examples/fieldtest/` it does not, because the loader
  resolves `(import std_list)` against the entry file's directory.
- **Why friction (not bug):** The loader behaviour is consistent
  with its existing one-directory-per-workspace model; the spec
  doesn't promise multi-directory loading. But the
  fieldtest_examples path is `examples/fieldtest/` per the project
  profile — every cross-module fixture either has to be entirely
  self-contained (no `(import std_*)`) or pull in symlinks to
  files in `examples/`. That tax falls on every future fieldtest.
  An LLM author writing a new program for the project would hit
  the same wall.
- **Secondary friction:** the error message lists only the
  `.ail.json` path even though the loader does in fact look for
  both `.ail` and `.ail.json` (the existing `examples/` tree has
  no `.ail.json` files for `std_list`, yet check there works). A
  user reading the diagnostic would conclude they need to author
  a `.ail.json` — a wrong reading.
- **Recommended downstream action:** `plan` — small tidy iteration:
  (a) loader looks up modules through a workspace-config search
  path (analogous to `examples/` itself), so fixtures can sit
  outside the canonical example directory without symlinks;
  AND/OR (b) diagnostic message updated to list both expected
  paths and to suggest the workspace config addition. Either
  half-fix removes the symlink-as-workaround pattern.

### [spec_gap] F9 — `unit` literal: design model uses it, checker rejects it
- **Example:** `kem_4_paramin_box_red.ail` first draft (before fix).
- **What happened:**
  ```
  ; before fix
  (fn main
    (type (fn-type (params) (ret (con Unit)) (effects IO)))
    (params)
    (body unit))
  ; resulting diagnostic:
  ;   [unbound-var] main: unknown identifier: `unit`
  ```
  `design/models/0007-kernel-extensions.md` line 80 uses `unit` as
  a value literal of type `Unit`. The checker treats it as a
  Term::Var lookup and emits `[unbound-var]`. The fix in
  `kem_4_paramin_box_red.ail` was to replace the body with
  `(do io/print_str "")` — a small awkwardness for any
  "intentionally empty effectful main" pattern.
- **Why spec_gap (not bug):** The whitepaper and design ledger
  may simply have been imprecise — there's no language contract
  pinning `unit` as a value literal. But the design model uses
  it as if it were canonical; an LLM author reading
  `design/models/0007` will reach for it.
- **Recommended downstream action:** `ratify` or
  `tighten the design ledger` — either add `unit` as the
  canonical Unit-value literal (a one-line resolver change) and
  document it, or remove the usage from
  `design/models/0007-kernel-extensions.md` and replace with the
  canonical idiom. The current state has the model and the
  surface disagreeing.

## Recommendation summary

| Finding | Class | Recommended action |
|---|---|---|
| F1 | bug | debug → implement (resolver: equate `<this>.T` with bare `T`) |
| F2 | friction | plan (tidy iteration: loader search path + diagnostic) |
| F3 | bug | debug → implement (auto-import registers module as qualifier) |
| F4 | bug | debug or ratify (stub: add `new` def, OR amend spec) |
| F5 | working | carry-on |
| F6 | working | carry-on |
| F7 | working | carry-on |
| F8 | working | carry-on |
| F9 | spec_gap | ratify OR tighten design model |

### Concerns / blockers

None. The two bugs (F1, F3) are diagnosis-clear and have minimal
repros in the working tree. F4 is a spec/ship coherence call —
either reading is coherent; needs an orchestrator decision.
