Files
AILang/docs/specs/0028-prelude-decouple.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

20 KiB

Prelude / core decoupling — Design Spec

Date: 2026-05-14 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude

Goal

Retire examples/prelude.ail.json from the working tree and remove all knowledge of the literal module name "prelude" from ailang-core. After this milestone:

  1. The single source of truth for the prelude is examples/prelude.ail (Form A). The compiler reads it via include_str! in ailang-surface and parses it with ailang_surface::parse at compile time of the embedding crate.
  2. ailang-core no longer embeds prelude bytes, no longer defines the ReservedModuleName error, no longer auto-injects a module named "prelude", and no longer hardcodes the string "prelude" in cross-module-reference diagnostics. core's Workspace loader becomes a pure module-graph + schema-validator with no language- content awareness.
  3. The §C4 (b) compile-time-embed carve-out in docs/specs/0025-form-a-default-authoring.md becomes empty and is struck. The seven §C4 (a) subject-matter-rejection fixtures remain the only .ail.json files in the corpus.

The motivation has two parts. First, the doctrine inconsistency: CLAUDE.md states "authors write .ail; the build derives the JSON- AST in-process via ailang_surface::parse". The prelude is the single most-read AILang file in the corpus; that it is the one file embedded into the compiler as JSON contradicts the doctrine. Second, the layering inconsistency: ailang-core's declared role per CLAUDE.md is "AST, canonicalisation, desugaring, workspace types, hash, pretty" — neither shipping language content nor encoding the identity of a privileged module. The literal-"prelude" references in core (8 occurrences across the inject step, the diagnostic candidate sites, and the test cases) are the symptom of that drift; this milestone removes them.

Out of scope:

  • The implicit-prelude resolution in ailang-check (crates/ailang-check/src/lib.rs:143-area) and the codegen-side implicit-prelude behaviour (iter 23.2.4). Both already consume the prelude module by name from a populated workspace; they do not embed prelude bytes and do not own the reservation. They keep working unchanged because surface still populates the prelude slot in every workspace.
  • The migrate-bare-cross-module-refs subcommand's defensive include_str! of prelude.ail.json at crates/ail/src/main.rs:475 — the inserted prelude is immediately skipped in the rewrite loop (if mod_name == "prelude" && path.as_os_str().is_empty() { continue; }), so this is a no-op relic. Iter 1 deletes it along with the core-side embed.

Architecture

The cut is the β.2 shape from the brainstorm: core becomes fully prelude-agnostic. The DFS-visitor and the schema-validation pipeline that today live as one block inside core::workspace::load_workspace_with split into two publicly composable phases. Surface composes them with a prelude-injection step in between.

Phase split

Today (in core):

load_workspace_with(entry, loader)
    DFS visit (loader-driven)
    insert prelude module (hardcoded inject)
    validate_canonical_type_names
    validate_classdefs
    build_registry
    -> Workspace

After the milestone:

core::load_modules_with(entry, loader)
    DFS visit (loader-driven)
    -> (entry_name, root_dir, BTreeMap<String, Module>)

surface::load_workspace(entry)
    let (entry_name, root_dir, mut modules) = core::load_modules_with(entry, load_module)
    if modules.contains_key("prelude") return Err(ReservedModuleName)
    modules.insert("prelude", parse_prelude())
    core::build_workspace(entry_name, root_dir, modules, &["prelude"])

core::build_workspace(entry_name, root_dir, modules, implicit_imports)
    validate_canonical_type_names(modules, implicit_imports)
    validate_classdefs(modules, implicit_imports)
    build_registry(modules)
    -> Workspace

Why two phases, not one

A single core::build_workspace that takes already-assembled modules works because every consumer of the validation pipeline (canonical-type-names, classdefs, registry) needs the modules map and the workspace shape — they do not need to know how those modules got there. Splitting at the loader/validator seam lets surface inject a special module without core knowing what makes it special.

Why implicit_imports: &[&str] parameter

The diagnostic sites in check_class_ref_qualifier (line 1084-area) and check_type_con_name (line 1148-area) currently scan local_classes.get("prelude") / local_types.get("prelude") as a fallback candidate when bare cross-module-refs fail to resolve. The hardcoded "prelude" is the last sprach-konzept-as-string in core's schema-validator path.

Replacing it with an implicit_imports: &[&str] parameter on validate_canonical_type_names and validate_classdefs parameterises the concept. core walks both the per-module declared imports AND the caller-supplied implicit imports without knowing what name makes one implicit. Surface passes &["prelude"]. A future caller could pass a different set without core needing to change.

This is not speculative generality (rejected in the brainstorm for β.3): it is the single mechanism that lets core stop hardcoding "prelude". Without parameterisation, the diagnostic sites would either keep the hardcoded string (failing the milestone goal) or the diagnostic candidate-list would lose its prelude-fallback suggestion (a regression in error quality).

Why ReservedModuleName moves to surface

The reservation "prelude" is an implementation choice of the prelude-owning crate, not of the workspace loader. core has no opinion on what module names are reserved; it discovers a graph of named modules and validates their schema. Surface, which injects a module named "prelude", owns the reservation that makes the injection safe (a user module of the same name would shadow). The WorkspaceLoadError enum keeps the variant for ABI stability; the variant moves to ailang-surface (or a new error type composed with core's) — the exact placement is a Step-7 detail, see Components.

Components

The change touches exactly three crates: ailang-core, ailang-surface, ail (CLI). Tests across the workspace adapt mechanically.

core's src/ retains zero literal-"prelude" strings post-milestone. The WorkspaceLoadError::ReservedModuleName variant declaration is generic ({ name: String }) and does not name "prelude"; the literal lives only at the single surface-side construction site. This is the binding meaning of "core becomes prelude-agnostic" in this spec.

ailang-core changes

  • crates/ailang-core/src/workspace.rs:

    • Remove: const PRELUDE_JSON (line 417), fn load_prelude (line 425), the inject step + reserved-name check inside load_workspace_with (lines 493-504), the literal-"prelude" fallbacks in check_class_ref_qualifier (lines 1084-1091) and check_type_con_name (lines 1148-1155).
    • Replace pub fn load_workspace_with<F>(entry, loader) with two new public fns:
      • pub fn load_modules_with<F>(entry, loader) -> Result<(String, PathBuf, BTreeMap<String, Module>), WorkspaceLoadError> — returns (entry_name, root_dir, modules) with no validation and no prelude.
      • pub fn build_workspace(entry_name, root_dir, modules, implicit_imports: &[&str]) -> Result<Workspace, WorkspaceLoadError> — runs the three-stage validation + registry construction. implicit_imports is forwarded to validate_canonical_type_names; the other two pipeline stages do not use it.
    • Retire pub fn load_workspace(entry) (the JSON-only legacy wrapper at line 437). It has no production callers; the few in-core tests that use it migrate to either surface::load_workspace via the existing dev-dep edge or to direct load_modules_with
      • build_workspace composition.
    • Move the production of WorkspaceLoadError::ReservedModuleName (line 315) out of core. The variant declaration stays in core's WorkspaceLoadError enum — its name and shape (ReservedModuleName { name: String }) reference no literal "prelude", so it does not by itself violate the decoupling. core's src/ contains zero call sites that construct the variant after this milestone; surface's load_workspace is the sole producer, supplying name: "prelude".to_string(). Downstream match-arms in CLI-diagnostic code are unaffected because the variant tag and field shape are unchanged.
    • Update validate_canonical_type_names's signature to take implicit_imports: &[&str]. Internal threading propagates the slice through the per-def walk to the two diagnostic-site helpers (check_class_ref_qualifier ~ line 1084 area, check_type_con_name ~ line 1148 area). validate_classdefs is not modified — it does not call those helpers and so has no consumer of the literal-"prelude" fallback. (Spec's first draft over-stated the threading scope; corrected here per the grounding-check agent's finding.)
  • crates/ailang-core/tests/workspace_pin.rs: the in-tree DFS expectations that today assume prelude is auto-injected get rewritten to use surface::load_workspace (already imported via dev-dep) — same expected outputs, different entry point.

  • crates/ailang-core/tests/carve_out_inventory.rs: remove the prelude.ail.json entry from the §C4 (b) list (line 25). The comment block at line 8 (§C4 (b) compile-time-embed: 1 fixture (prelude.ail.json).) gets updated to "0 fixtures" and notes the retirement.

ailang-surface changes

  • crates/ailang-surface/src/loader.rs:
    • Add const PRELUDE_AIL: &str = include_str!("../../../examples/prelude.ail");
    • Add fn parse_prelude() -> Module returning the parsed prelude. Like the old core::load_prelude, this panics on parse failure (build-time-validated).
    • Rewrite load_workspace(entry) to compose the new core primitives:
      pub fn load_workspace(entry: &Path) -> Result<Workspace, WorkspaceLoadError> {
          let (entry_name, root_dir, mut modules) =
              ailang_core::load_modules_with(entry, load_module)?;
          if modules.contains_key("prelude") {
              return Err(WorkspaceLoadError::ReservedModuleName {
                  name: "prelude".to_string(),
              });
          }
          modules.insert("prelude".to_string(), parse_prelude());
          ailang_core::build_workspace(entry_name, root_dir, modules, &["prelude"])
      }
      
    • The load_module extension-dispatcher (line 37) is unchanged.

ail (CLI) changes

  • crates/ail/src/main.rs: delete the defensive prelude-include block in the migrate-bare-cross-module-refs subcommand (lines 472-484). The synthetic prelude entry was inserted only to satisfy the loop's per-module iteration, then unconditionally skipped in the rewrite. Removing both the include and the skip branch is net-zero behaviour with one fewer dependency on prelude.ail.json.

Working-tree changes

  • Delete examples/prelude.ail.json. After this commit the prelude exists on disk only as examples/prelude.ail.
  • After form-a.1 (T9), the per-pair every_ail_fixture_matches_its_json_counterpart test was retired (no production .ail/.ail.json pairs remain in the corpus except prelude itself). The deterministic-parse property (parse_is_deterministic_over_every_ail_fixture and parse_then_print_then_parse_is_idempotent_on_every_ail_fixture in crates/ailang-surface/tests/round_trip.rs) stays as the binding invariant for prelude.ail's parse stability.
  • Cross-form-identity preflight (mandatory before pd.2 swaps the embed source — see Testing Strategy below for the mechanism): capture module_hash(serde_json::from_str(prelude.ail.json)) in pd.2 BEFORE removing the JSON embed, and assert that module_hash(parse(prelude.ail)) equals it. Without this preflight, the embed swap could silently change the canonical prelude Module (different parse-path producing structurally different AST) and propagate to every workspace's prelude identity.

Data flow

The runtime data flow does not change for any consumer. After the milestone:

[examples/prelude.ail]
        |
        | include_str! (compile-time, into ailang-surface)
        v
[PRELUDE_AIL: &'static str]
        |
        | parse_prelude() (called at every workspace load)
        v
[Module]
        |
        v
surface::load_workspace
        |
        +--> core::load_modules_with     # DFS + per-module load
        |        |
        |        v
        |    BTreeMap<String, Module>    # no prelude yet
        |        |
        | [check ReservedModuleName, insert prelude]
        |        |
        |        v
        +--> core::build_workspace        # validate + registry
                 |
                 v
              Workspace                   # ready for check / codegen

parse_prelude() is called per workspace load (not memoised). The prelude is ~120 lines; its parse cost is below the noise floor of the rest of workspace loading (DFS reads, JSON deserialisation of user modules, validation). If a future profiling pass shows it as a hot spot, surface can introduce a OnceLock<Module> cache locally without any API change.

Error handling

Existing error semantics — preserved

  • WorkspaceLoadError::ReservedModuleName { name: "prelude" } — same shape, fired by surface instead of core. Match-arms in diagnostic-rendering code are unaffected because the enum variant itself is unchanged.
  • WorkspaceLoadError::BareCrossModuleClassRef / BareCrossModuleTypeRef candidate lists — same shape, same contents. The prelude-fallback candidate is now produced by the generic implicit_imports walk, not by the hardcoded string.

Build-time error: malformed prelude.ail

If examples/prelude.ail ever becomes unparseable, parse_prelude() panics — same failure mode as the old load_prelude()'s expect("must parse as a Module"). This is acceptable because the prelude is build-time-validated by every test run; a malformed prelude is a build-correctness bug, not a runtime concern.

Migration error: stale callers of removed APIs

The retired core::load_workspace and the old core::load_workspace_with signature are not behind any deprecation shim. Out-of-tree callers (if any existed) would break at compile time with a clear "function not found" / "wrong arity" error, not silently. Per the brainstorm this milestone is internal-API-only; no stability promise to preserve.

Testing strategy

Three new pin tests, all in crates/ailang-surface/tests/:

  1. prelude_parse_pin.rs — pins that parse_prelude() succeeds and produces a Module with name == "prelude" and an expected minimum def-count (e.g. >= 8 covering the four classes shipped through milestones 22-24 plus their primitive instances). Guards against prelude.ail being silently emptied.

  2. prelude_module_hash_pin.rs — pins the module_hash of the parsed prelude against a literal hex string captured at iter close. This is the round-trip-stability anchor: any change to prelude.ail that does not also re-parse to the same canonical Module value flips this test red. The hex updates intentionally on prelude-content changes, with a JOURNAL entry naming why.

    Cross-form-identity preflight (added to the SAME test file as the hash pin, in pd.2): before deleting the JSON embed source, compute let expected = module_hash(serde_json::from_str(PRELUDE_JSON)) from the still-present prelude.ail.json bytes, then assert module_hash(parse(prelude.ail)) == expected. This pins the single load-bearing assumption that pd.2 introduces — that the .ail parse path produces a Module structurally identical to what the .ail.json deserialize path produces for the same prelude content. The retired every_ail_fixture_matches_its_json_counterpart test asserted this for the entire corpus; this pin asserts it for the one file that survives the corpus flip. The pin runs once (during pd.2), then the JSON-half of the comparison disappears at pd.3 along with the JSON file. The pin file itself transitions to parse-only after pd.3.

  3. prelude_decouple_carve_out_pin.rs — asserts that the file examples/prelude.ail.json does NOT exist. Pins the milestone's stated artefact retirement; rewriting it back into the tree surfaces immediately.

Existing tests that continue to pin behaviour:

  • crates/ailang-core/tests/workspace_pin.rs — DFS + module-graph shape pins. Updated to call surface::load_workspace; outputs identical.
  • crates/ailang-core/tests/carve_out_inventory.rs — list updated to reflect §C4 (b) becoming empty.
  • The ~22 ailang_surface::load_workspace callers across the workspace (production code in crates/ail/src/main.rs plus tests in ailang-check/, ailang-codegen/, ail/tests/, ailang-core/tests/) — public entry point unchanged, so no test body or assertion changes. Verified by per-crate cargo test --workspace green at unchanged baselines.

Regression baselines (bench/check.py, bench/compile_check.py, bench/cross_lang.py) must stay green. None of them are expected to regress because the runtime cost is identical (one parse per workspace load, against a 120-line file).

Acceptance criteria

The milestone closes when ALL of the following hold:

  1. examples/prelude.ail.json is removed from the working tree.
  2. cargo build --workspace succeeds with no include_str! reference to prelude.ail.json anywhere in the workspace. Verified by git grep -l 'prelude\.ail\.json' -- '*.rs' returning only test/comment hits, not include_str! sites.
  3. git grep -l '"prelude"' crates/ailang-core/src/ returns no matches (zero literal-"prelude" strings in core's src/).
  4. cargo test --workspace is green, including the three new pins above.
  5. bench/check.py, bench/compile_check.py, bench/cross_lang.py are green at unchanged baselines.
  6. docs/specs/0025-form-a-default-authoring.md §C4 (b) is updated: the carve-out list is empty; the section either deletes the (b) sub-bullet or notes "retired by prelude-decouple milestone, 2026-05-14".
  7. CLAUDE.md and docs/DESIGN.md reviewed: no statement still asserts that core embeds the prelude. (Spot check — the relevant CLAUDE.md sentence is the "authors write .ail" line, which already aligns with the post-milestone state.)

Iteration sketch

Tentative iter split (refined by planner):

  • iter 1 — pd.1 core API split. Introduce load_modules_with and build_workspace; refit load_workspace_with as a deprecated alias if needed during the transition (or delete outright). Thread implicit_imports: &[&str] through the two validation functions and their internal helpers. Move ReservedModuleName off the core construction path. core changes only; no surface changes yet. Tests stay green using the old wiring (surface unchanged).

  • iter 2 — pd.2 surface assumes ownership. Add PRELUDE_AIL

    • parse_prelude to ailang-surface. Rewrite surface::load_workspace to compose the new core primitives and inject the parsed prelude. Remove the prelude embed from core. Add the three new pins. Critical preflight: install the cross-form-identity test (see Testing Strategy above) BEFORE deleting the core-side JSON embed. The test must be green with both forms still present, so the swap is justified by an active green pin and not by hope.
  • iter 3 — pd.3 retire prelude.ail.json + carve-out cleanup. Delete examples/prelude.ail.json. Delete the defensive include in the migrate-subcommand. Update carve_out_inventory.rs and the form-a-default-authoring spec §C4 (b). Run all benches.

The split is for plan visibility; the orchestrator may collapse to two iters if pd.1 and pd.2 turn out small enough that one cohesive commit covers both. pd.3 is its own iter so the artefact retirement is auditable in isolation.