Files
AILang/docs/specs/0053-fieldtest-kernel-extension-mechanics.md
Brummel aa49a56d5a fieldtest: kernel-extension-mechanics — 6 examples, 9 findings (3 bugs, 1 friction, 1 spec_gap, 4 working)
Post-audit fieldtest for the kernel-extension-mechanics milestone.
Fieldtester wrote 6 .ail consumer programs against the public
interface only (design ledger + spec + ail CLI; no source crate
reads), one per axis with two for the param-in pair (accept +
reject). All artefacts in examples/fieldtest/ + the spec.

**Working (4):** F5 NewTypeNotConstructible diagnostic is precise
(names the home module + missing def in one sentence). F6 Term::New
codegen-deferral diagnostic explicitly names the raw-buf milestone
where support lands. F7 param-in accept-path is end-to-end (check +
build + run prints 17). F8 ParamNotInRestrictedSet names type-arg
+ var + TypeDef.

**Bugs (3, action: debug):**

- F1 (axis 1) — Same-module type-scoped call `(app Type.member …)`
  rejected with phantom-qualified `expected <this>.T, got T`.
  Bare `(app member …)` from inside Type's home module works.
  Spec's "Canonical form decision" promises type-scoped works
  uniformly; the resolver appears to disagree with the workspace
  pre-pass on whether `<this-module>.T` equals bare `T`. Repro:
  examples/fieldtest/kem_2b_min_repro.ail.

- F3 (axis 3) — Kernel-tier auto-import scopes the name but does
  not register the module as a resolvable qualifier prefix. Per
  the pre-pass, a bare `StubT` in a type slot is qualified to
  `kernel_stub.StubT`; the resolver then rejects `kernel_stub`
  as unknown. Asymmetric: prelude's free fns work (per
  prelude_free_fns.rs), but the stub's TypeDef is effectively
  unreachable from any consumer. Repro:
  examples/fieldtest/kem_3_stub_consumer.ail.

- F4 (axis 3, spec/ship coherence) — The spec's prep.3 worked-
  consumer relies on `(new StubT 42)`, but the shipped STUB_AIL
  has only a TypeDef + ctor — the `(fn new ...)` the spec
  designed is missing from the implementation. The diagnostic
  the resolver does emit on the consumer (F5) is precise; the
  bug is the absent def itself. Resolution: re-add the `new` to
  STUB_AIL (the spec's design), refresh the drift pin.

**Friction (1, action: plan-or-defer):**

- F2 — Loader's sibling-only module resolution forces symlinks
  for any cross-module fixture sitting outside the canonical
  `examples/` directory. The kem_1 example required local
  symlinks for std_list.ail + std_maybe.ail. Secondary: the
  diagnostic message names only the .ail.json path even though
  .ail also resolves — actively misleading. Recommended: small
  tidy iter for loader workspace-search-path + diagnostic clarity.

**Spec-gap (1, action: ratify):**

- F9 — `design/models/0007-kernel-extensions.md:80` uses `unit`
  as a value literal of type Unit; the checker treats it as a
  Term::Var lookup and emits [unbound-var]. Either ratify `unit`
  as the canonical Unit-value literal in the language, or tighten
  the design model to use the existing idiom. The current model/
  surface disagreement is a small but real LLM-author trap.

**Symlinks committed** as evidence of the F2 workaround:
examples/fieldtest/std_list.ail and std_maybe.ail are symlinks
into ../. They are the fingerprint of the friction — removing
them silently would erase the evidence.

Per Iron Law, fieldtester did not work around bugs — they were
all surfaced and recorded. The kem_2b_min_repro.ail fixture
exists specifically because F1 surfaced mid-drafting and got
minimised; kem_3_stub_consumer.ail also stays as the F3 RED-side
fixture.

Triage (next-step routing for /boss):

  F4 → small inline fix (add (fn new ...) to STUB_AIL + drift refresh)
  F9 → small inline ratify (whitepaper edit)
  F2 → backlog issue, deferred (single tidy iter someday)
  F1, F3 → debug skill dispatches, then implement mini-mode
  F5-F8 → carry-on, recorded as wins
2026-05-28 19:19:00 +02:00

17 KiB

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 Stris not in the restricted set for type-variableaofNumBox``. 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 StubTis not constructible: home modulekernel_stubdeclares nonew 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 Stris not in the restricted set for type-variableaofNumBox`` 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.