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.
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:
- The single source of truth for the prelude is
examples/prelude.ail(Form A). The compiler reads it viainclude_str!inailang-surfaceand parses it withailang_surface::parseat compile time of the embedding crate. ailang-coreno longer embeds prelude bytes, no longer defines theReservedModuleNameerror, no longer auto-injects a module named"prelude", and no longer hardcodes the string"prelude"in cross-module-reference diagnostics. core'sWorkspaceloader becomes a pure module-graph + schema-validator with no language- content awareness.- The §C4 (b) compile-time-embed carve-out in
docs/specs/0025-form-a-default-authoring.mdbecomes empty and is struck. The seven §C4 (a) subject-matter-rejection fixtures remain the only.ail.jsonfiles 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-refssubcommand's defensiveinclude_str!ofprelude.ail.jsonatcrates/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 insideload_workspace_with(lines 493-504), the literal-"prelude"fallbacks incheck_class_ref_qualifier(lines 1084-1091) andcheck_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_importsis forwarded tovalidate_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 eithersurface::load_workspacevia the existing dev-dep edge or to directload_modules_withbuild_workspacecomposition.
- Move the production of
WorkspaceLoadError::ReservedModuleName(line 315) out of core. The variant declaration stays in core'sWorkspaceLoadErrorenum — its name and shape (ReservedModuleName { name: String }) reference no literal"prelude", so it does not by itself violate the decoupling. core'ssrc/contains zero call sites that construct the variant after this milestone; surface'sload_workspaceis the sole producer, supplyingname: "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 takeimplicit_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_classdefsis 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.)
- Remove:
-
crates/ailang-core/tests/workspace_pin.rs: the in-tree DFS expectations that today assume prelude is auto-injected get rewritten to usesurface::load_workspace(already imported via dev-dep) — same expected outputs, different entry point. -
crates/ailang-core/tests/carve_out_inventory.rs: remove theprelude.ail.jsonentry 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() -> Modulereturning the parsed prelude. Like the oldcore::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_moduleextension-dispatcher (line 37) is unchanged.
- Add
ail (CLI) changes
crates/ail/src/main.rs: delete the defensive prelude-include block in themigrate-bare-cross-module-refssubcommand (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 onprelude.ail.json.
Working-tree changes
- Delete
examples/prelude.ail.json. After this commit the prelude exists on disk only asexamples/prelude.ail. - After form-a.1 (T9), the per-pair
every_ail_fixture_matches_its_json_counterparttest was retired (no production.ail/.ail.jsonpairs remain in the corpus except prelude itself). The deterministic-parse property (parse_is_deterministic_over_every_ail_fixtureandparse_then_print_then_parse_is_idempotent_on_every_ail_fixtureincrates/ailang-surface/tests/round_trip.rs) stays as the binding invariant forprelude.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 thatmodule_hash(parse(prelude.ail))equals it. Without this preflight, the embed swap could silently change the canonical preludeModule(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/BareCrossModuleTypeRefcandidate lists — same shape, same contents. The prelude-fallback candidate is now produced by the genericimplicit_importswalk, 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/:
-
prelude_parse_pin.rs— pins thatparse_prelude()succeeds and produces aModulewithname == "prelude"and an expected minimum def-count (e.g.>= 8covering the four classes shipped through milestones 22-24 plus their primitive instances). Guards againstprelude.ailbeing silently emptied. -
prelude_module_hash_pin.rs— pins themodule_hashof the parsed prelude against a literal hex string captured at iter close. This is the round-trip-stability anchor: any change toprelude.ailthat does not also re-parse to the same canonicalModulevalue 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-presentprelude.ail.jsonbytes, then assertmodule_hash(parse(prelude.ail)) == expected. This pins the single load-bearing assumption that pd.2 introduces — that the.ailparse path produces aModulestructurally identical to what the.ail.jsondeserialize path produces for the same prelude content. The retiredevery_ail_fixture_matches_its_json_counterparttest 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. -
prelude_decouple_carve_out_pin.rs— asserts that the fileexamples/prelude.ail.jsondoes 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 callsurface::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_workspacecallers across the workspace (production code incrates/ail/src/main.rsplus tests inailang-check/,ailang-codegen/,ail/tests/,ailang-core/tests/) — public entry point unchanged, so no test body or assertion changes. Verified by per-cratecargo test --workspacegreen 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:
examples/prelude.ail.jsonis removed from the working tree.cargo build --workspacesucceeds with noinclude_str!reference toprelude.ail.jsonanywhere in the workspace. Verified bygit grep -l 'prelude\.ail\.json' -- '*.rs'returning only test/comment hits, notinclude_str!sites.git grep -l '"prelude"' crates/ailang-core/src/returns no matches (zero literal-"prelude"strings in core'ssrc/).cargo test --workspaceis green, including the three new pins above.bench/check.py,bench/compile_check.py,bench/cross_lang.pyare green at unchanged baselines.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".- CLAUDE.md and
docs/DESIGN.mdreviewed: 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.1core API split. Introduceload_modules_withandbuild_workspace; refitload_workspace_withas a deprecated alias if needed during the transition (or delete outright). Threadimplicit_imports: &[&str]through the two validation functions and their internal helpers. MoveReservedModuleNameoff the core construction path. core changes only; no surface changes yet. Tests stay green using the old wiring (surface unchanged). -
iter 2 —
pd.2surface assumes ownership. AddPRELUDE_AILparse_preludetoailang-surface. Rewritesurface::load_workspaceto 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.3retireprelude.ail.json+ carve-out cleanup. Deleteexamples/prelude.ail.json. Delete the defensive include in the migrate-subcommand. Updatecarve_out_inventory.rsand 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.