diff --git a/docs/plans/2026-05-14-iter-pd.2.md b/docs/plans/2026-05-14-iter-pd.2.md new file mode 100644 index 0000000..0f242c9 --- /dev/null +++ b/docs/plans/2026-05-14-iter-pd.2.md @@ -0,0 +1,802 @@ +# pd.2 — surface assumes prelude ownership; core shim retired — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-14-prelude-decouple.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Move the prelude embed (`PRELUDE_JSON` const + `load_prelude` fn) and +the prelude-injection step (with the `ReservedModuleName` reservation) out of +`ailang-core` into `ailang-surface`. Rewrite `surface::load_workspace` to call +`core::load_modules_with` + caller-side prelude inject + `core::build_workspace` +directly. Retire the pd.1 compatibility shim `core::load_workspace_with` along +with `load_one` and the `load_module` test-only import. Install the +cross-form-identity preflight test BEFORE the JSON file gets deleted in pd.3, +so the embed-source swap is justified by an active green pin and not by +hope. + +**Architecture:** post-pd.2 the prelude lives entirely in surface: the bytes +come from `examples/prelude.ail` via `include_str!`, parsing goes through +`ailang_surface::parse`, the reserved-name check (`ReservedModuleName`) fires +from surface, the implicit-imports list `&["prelude"]` is supplied at the +surface call site. Core's `src/` retains zero literal-`"prelude"` strings; +the `WorkspaceLoadError::ReservedModuleName` variant declaration stays in +core's enum (generic shape, no literal in the name) so downstream +match-arms (notably `crates/ail/src/main.rs:1220-1229`) keep working. + +**Tech Stack:** `ailang-surface` (loader.rs + new tests/), `ailang-core` +(workspace.rs deletions + lib.rs rustdoc updates + test-mod re-migration). +No `ail` CLI changes. No codegen / check changes. + +--- + +**Files this plan creates or modifies:** + +- Create: `crates/ailang-surface/tests/prelude_parse_pin.rs` — pins + `parse_prelude()` succeeds; module name is `"prelude"`; def-count + meets a minimum bound (guards against empty prelude). +- Create: `crates/ailang-surface/tests/prelude_module_hash_pin.rs` — two + `#[test]` fns: (a) `prelude_parse_yields_canonical_hash` pins + `module_hash(parse_prelude())` against a literal hex captured at iter + close; (b) `prelude_ail_and_json_parse_to_identical_module` is the + cross-form-identity preflight that asserts + `module_hash(serde_json::from_str(prelude.ail.json bytes)) == + module_hash(parse(prelude.ail bytes))`. Both are green at pd.2 close; + pd.3 drops the (b) fn along with the JSON file. +- Modify: `crates/ailang-surface/src/loader.rs:1-71` — add `PRELUDE_AIL` + const + `parse_prelude` fn; rewrite `load_workspace` body from the + current 3-line `load_workspace_with(entry, load_module)` shim-call to + the 7-line composition `(load_modules_with → reserved-name check → + inject parse_prelude → build_workspace(&["prelude"]))`. Update import + line at line 16 (`load_workspace_with` swaps to `load_modules_with, + build_workspace`). Update module rustdoc at lines 9-13 (the + `load_workspace_with` cross-reference is stale). +- Modify: `crates/ailang-core/src/workspace.rs` — delete `PRELUDE_JSON` + const (~lines 428-430), `load_prelude` fn (~lines 432-439), + `load_workspace_with` shim (~lines 525-561 including the doc-comment + at 525-540). Re-migrate 10 in-mod test-fns from + `load_workspace_with(&entry, load_one)` to `surface::load_workspace`, + re-migrate 1 in-mod test from `load_workspace_with` to + `load_modules_with` directly with an inline-closure loader, delete 1 + in-mod test (`load_workspace_with_shim_preserves_today_semantics` — + subject gone). Delete the now-orphaned `#[cfg(test)] fn load_one` and + the `#[cfg(test)] use crate::load_module;` import. Update the module + rustdoc at lines 1-32 (replaces pd.1's "single core entry point" framing + with "core exposes the two-phase composition; surface owns the public + entry point"). +- Modify: `crates/ailang-core/src/lib.rs` — three rustdoc sites that + reference `workspace::load_workspace_with` (lines 16-21, 50-51, 120 + per recon) update to point at `workspace::load_modules_with` + + `workspace::build_workspace`, with intra-doc cross-references to + `ailang_surface::load_workspace` for the public composition. The + `pub use workspace::{module_hash, Workspace, WorkspaceLoadError};` + re-export at line 73 is unchanged. + +--- + +### Task 1: Add `PRELUDE_AIL` const + `parse_prelude` fn to surface + +**Files:** +- Modify: `crates/ailang-surface/src/loader.rs` — add const + fn near the + top of the file, above the existing `is_ail_source` helper. + +- [ ] **Step 1: Write the failing test** + +Add a new file `crates/ailang-surface/tests/prelude_parse_pin.rs`. The +test asserts `parse_prelude()` is callable from outside the crate +(integration-test reach), succeeds, returns a `Module` named `"prelude"`, +and has a minimum number of defs. + +```rust +//! pd.2 (`prelude-decouple` milestone): pin that the surface-side +//! `parse_prelude` succeeds against the in-tree `examples/prelude.ail` +//! and returns a `Module` with the expected shape. Guards against +//! `prelude.ail` being silently emptied or `parse_prelude` regressing. +//! +//! The hash-stability + cross-form-identity pins live in +//! `prelude_module_hash_pin.rs`. + +use ailang_surface::parse_prelude; + +#[test] +fn parse_prelude_returns_module_named_prelude_with_min_defs() { + let m = parse_prelude(); + assert_eq!(m.name, "prelude"); + // Conservative lower bound: the prelude has shipped four classes + // (Eq, Ord, Show + the Ordering data def) plus their primitive + // instances by milestone 24. A future content addition only + // raises the count; an accidental emptying (or a renamed root) + // trips this immediately. + assert!( + m.defs.len() >= 8, + "expected >= 8 defs in prelude, got {}", + m.defs.len() + ); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p ailang-surface --test prelude_parse_pin` +Expected: FAIL with "cannot find function `parse_prelude` in module `ailang_surface`" +(and "no item named `parse_prelude`"). + +- [ ] **Step 3: Add `PRELUDE_AIL` const + `parse_prelude` fn to `loader.rs`** + +Edit `crates/ailang-surface/src/loader.rs`. Add immediately above the +existing `fn is_ail_source` (line 19 area): + +```rust +/// pd.2 (`prelude-decouple` milestone): the prelude bytes are embedded +/// here at compile time. The single source of truth is +/// `examples/prelude.ail` (Form A). Pre-pd.2, the embed lived in +/// `ailang-core` and used `prelude.ail.json`; that path retired with +/// the loader-split. +pub const PRELUDE_AIL: &str = include_str!("../../../examples/prelude.ail"); + +/// pd.2: parse the embedded prelude bytes into a `Module`. +/// +/// Panics on parse failure — the prelude is build-time-validated by +/// every test run, so a parse failure here is a build-correctness +/// bug, not a runtime concern. +/// +/// Mirrors the semantics of the retired `ailang_core::workspace::load_prelude` +/// (which deserialised `prelude.ail.json` via `serde_json::from_str`); +/// equivalence between the two parse paths is pinned by +/// `crates/ailang-surface/tests/prelude_module_hash_pin.rs`'s +/// cross-form-identity preflight. +pub fn parse_prelude() -> ailang_core::ast::Module { + crate::parse(PRELUDE_AIL).expect("examples/prelude.ail must parse as a Module") +} +``` + +`pub` is required for `parse_prelude` because the integration test (and +later, any out-of-crate consumer) needs to call it. `PRELUDE_AIL` is +also `pub` for the cross-form-identity preflight test to reach the same +bytes. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cargo test -p ailang-surface --test prelude_parse_pin` +Expected: PASS. + +- [ ] **Step 5: Run the surface crate test suite** + +Run: `cargo test -p ailang-surface` +Expected: all tests green; the new `parse_prelude` is additive. + +--- + +### Task 2: Write the cross-form-identity preflight + module-hash pin + +**Files:** +- Create: `crates/ailang-surface/tests/prelude_module_hash_pin.rs` — two + `#[test]` fns (cross-form-identity + hash pin). + +This task captures the load-bearing assumption that swapping the embed +source from `prelude.ail.json` to `prelude.ail` does not change the +canonical `Module`. Both files are still present at pd.2-time, so the +preflight can compare them directly. pd.3 drops the JSON-side fn along +with the JSON file. + +- [ ] **Step 1: Write the failing tests** + +Create `crates/ailang-surface/tests/prelude_module_hash_pin.rs`: + +```rust +//! pd.2 (`prelude-decouple` milestone): pin the canonical identity of +//! the parsed prelude. +//! +//! Two tests: +//! 1. `prelude_ail_and_json_parse_to_identical_module` — cross-form- +//! identity preflight. Asserts that parsing `examples/prelude.ail` +//! via `ailang_surface::parse` produces a `Module` with the same +//! `module_hash` as deserialising `examples/prelude.ail.json` via +//! `serde_json::from_str`. This is the load-bearing assumption +//! that justifies pd.3's deletion of the JSON file. pd.3 drops +//! this test along with the JSON. +//! 2. `prelude_parse_yields_canonical_hash` — long-term anchor. +//! Asserts the literal hex hash of the parsed Module. Updates +//! intentionally on prelude-content changes (with a JOURNAL entry +//! naming why); accidental drift trips immediately. + +use ailang_core::workspace::module_hash; +use ailang_surface::{parse_prelude, PRELUDE_AIL}; + +const PRELUDE_JSON: &str = include_str!("../../../examples/prelude.ail.json"); + +#[test] +fn prelude_ail_and_json_parse_to_identical_module() { + // Form A path: parse prelude.ail via ailang_surface::parse. + let from_ail = parse_prelude(); + + // Form B path: deserialise prelude.ail.json via serde_json (the + // pre-pd.2 path; matches what `ailang_core::workspace::load_prelude` + // did before retirement). + let from_json: ailang_core::ast::Module = serde_json::from_str(PRELUDE_JSON) + .expect("examples/prelude.ail.json must deserialise"); + + let h_ail = module_hash(&from_ail); + let h_json = module_hash(&from_json); + + assert_eq!( + h_ail, h_json, + "cross-form-identity preflight failed:\n parse(prelude.ail) hash: {}\n deserialize(prelude.ail.json) hash: {}\n\ + The two parse paths produce structurally different `Module` values for the same prelude content. \ + pd.3 must NOT delete prelude.ail.json until this is investigated.", + h_ail, h_json, + ); +} + +#[test] +fn prelude_parse_yields_canonical_hash() { + let m = parse_prelude(); + let h = module_hash(&m); + // The expected hex is captured at pd.2-iter close (Step 5 of this task). + // It updates intentionally on prelude-content changes; record the why + // in the per-iter journal entry. + assert_eq!( + h, "PLACEHOLDER_TO_BE_REPLACED_AT_STEP_5", + "prelude module hash drifted; if intentional, capture the new \ + hex below + record the why in the per-iter journal." + ); +} + +// PRELUDE_AIL re-exported for any future test that wants the raw bytes. +const _: &str = PRELUDE_AIL; +``` + +NOTE: the literal hex in `prelude_parse_yields_canonical_hash` is a +placeholder that gets replaced at Step 5 of this task once the test is +green. The `cross-form-identity` test does NOT contain a literal — it +asserts equality between two computed values, so it doesn't need a +captured hex. + +- [ ] **Step 2: Run tests to observe failure modes** + +Run: `cargo test -p ailang-surface --test prelude_module_hash_pin` +Expected: +- `prelude_ail_and_json_parse_to_identical_module` may PASS or FAIL + depending on whether the two parse paths actually agree today. If it + PASSES, we are clear to ship. If it FAILS, **STOP** and report — pd.3 + cannot proceed; the spec's load-bearing assumption is violated and + the milestone needs a brainstorm-level rethink. +- `prelude_parse_yields_canonical_hash` FAILS with the placeholder + comparison (placeholder != real hash). + +- [ ] **Step 3: If the cross-form-identity test PASSED in Step 2, + capture the canonical hex hash** + +Read the failure message of `prelude_parse_yields_canonical_hash` to +get the actual computed hash; the assert prints both expected and +actual. Record the actual hex string. + +If the test message is unclear, run a one-shot helper: + +```bash +cargo test -p ailang-surface --test prelude_module_hash_pin prelude_parse_yields_canonical_hash -- --nocapture 2>&1 | grep -E '^( *left:| *right:)' +``` + +The "right" line carries the actual computed hash. + +- [ ] **Step 4: If the cross-form-identity test FAILED in Step 2, STOP** + +Do NOT proceed with Task 3+. Write a per-iter journal entry under +`docs/journals/2026-05-14-iter-pd.2.md` capturing: +- The two computed hashes that disagree. +- Which Module fields differ (run a manual diff via reading both + Modules into Rust and pretty-printing — or use `git grep` on the + relevant `serde_json::from_str` Display output to localise the + divergence). +- A statement that the milestone is BLOCKED pending a brainstorm-level + decision on whether to retire the JSON or keep it. + +This branch is unlikely (the round-trip CI through form-a.1 has been +exercising parse-then-print idempotency on every `.ail` fixture), but +the test exists precisely to catch the case where the assumption +fails. + +- [ ] **Step 5: Replace the placeholder hex with the real hash** + +Edit `prelude_module_hash_pin.rs`: + +```rust + assert_eq!( + h, "", // captured at pd.2 iter close + ... + ); +``` + +- [ ] **Step 6: Re-run tests, expect both green** + +Run: `cargo test -p ailang-surface --test prelude_module_hash_pin` +Expected: BOTH tests PASS. + +--- + +### Task 3: Rewrite `surface::load_workspace` body + +**Files:** +- Modify: `crates/ailang-surface/src/loader.rs:69-71` — replace the + 3-line shim-call with the 7-line composition. + +- [ ] **Step 1: Write the regression-pin test for the new composition** + +Surface's existing `tests/` already contain end-to-end tests of +`load_workspace`. Add ONE new test specifically pinning that a fresh +workspace contains the prelude module, and that a user module trying +to take the name `prelude` is rejected with `ReservedModuleName`. + +Append to an appropriate existing test file in `crates/ailang-surface/tests/`, +or create a new file `crates/ailang-surface/tests/prelude_injection_pin.rs`: + +```rust +//! pd.2 (`prelude-decouple` milestone): pin that +//! `surface::load_workspace` injects the prelude into every workspace +//! and rejects user modules trying to take the reserved name. + +use ailang_core::workspace::WorkspaceLoadError; +use ailang_surface::load_workspace; +use std::path::Path; + +#[test] +fn surface_load_workspace_injects_prelude_into_every_workspace() { + // Use any existing single-module example from `examples/`. The hello + // example is a stable choice (used by other surface tests). + let entry = Path::new("../../examples/hello.ail.json"); + let ws = load_workspace(entry).expect("load"); + assert!( + ws.modules.contains_key("prelude"), + "prelude must appear in every workspace post-pd.2" + ); +} + +#[test] +fn surface_load_workspace_rejects_user_module_named_prelude() { + let dir = tempfile::tempdir().unwrap(); + // Create a JSON-form fixture for the user-side "prelude" module. + // Surface's loader should fail with ReservedModuleName. + let prelude_path = dir.path().join("prelude.ail.json"); + std::fs::write( + &prelude_path, + r#"{"name":"prelude","imports":[],"defs":[]}"#, + ) + .unwrap(); + // Entry is a separate module named "main" that imports the user's + // prelude — actually, since prelude is implicit, just point the + // entry at prelude itself: surface checks the entry-module name + // against the file stem first, so a single-file workspace where + // the entry IS named "prelude" trips the reservation. + let err = load_workspace(&prelude_path).expect_err("must reject"); + match err { + WorkspaceLoadError::ReservedModuleName { name } => { + assert_eq!(name, "prelude"); + } + other => panic!("expected ReservedModuleName, got: {other:?}"), + } +} +``` + +If a similar test already exists in `crates/ailang-surface/tests/`, +extend it instead of duplicating. The recon did not enumerate the +existing surface test files; check with `ls crates/ailang-surface/tests/` +before creating. + +- [ ] **Step 2: Run the new tests against today's implementation + (still going through the shim)** + +Run: `cargo test -p ailang-surface --test prelude_injection_pin` +Expected: BOTH tests PASS — the shim is still wired today and behaves +correctly. This is the baseline before the rewrite. + +- [ ] **Step 3: Rewrite `surface::load_workspace`** + +Edit `crates/ailang-surface/src/loader.rs`. The current body at lines +69-71: + +```rust +pub fn load_workspace(entry: &Path) -> Result { + load_workspace_with(entry, load_module) +} +``` + +becomes: + +```rust +pub fn load_workspace(entry: &Path) -> Result { + let (entry_name, root_dir, mut modules) = + ailang_core::workspace::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::workspace::build_workspace( + entry_name, + root_dir, + modules, + &["prelude"], + ) +} +``` + +Update the imports at line 16 from: + +```rust +use ailang_core::workspace::{load_workspace_with, Workspace, WorkspaceLoadError}; +``` + +to: + +```rust +use ailang_core::workspace::{Workspace, WorkspaceLoadError}; +``` + +(The `load_modules_with` and `build_workspace` calls use the fully- +qualified path `ailang_core::workspace::*` for clarity at the call +site — the body is the only consumer in this file.) + +Update the module rustdoc at lines 9-13: + +```rust +//! These functions live in `ailang-surface` rather than +//! `ailang-core` because `ailang-core` cannot import `ailang-surface` +//! without creating a circular crate dependency. The cross-crate +//! injection point is [`ailang_core::workspace::load_modules_with`] +//! (DFS-only loader) composed with [`ailang_core::workspace::build_workspace`] +//! (validation + registry). Surface owns the prelude inject step +//! between the two; the implicit-imports list `&["prelude"]` is +//! supplied at the surface call site. Introduced in iter pd.2 (post- +//! ext-cli.1's `load_workspace_with` shim retirement). +``` + +- [ ] **Step 4: Run the regression-pin tests** + +Run: `cargo test -p ailang-surface --test prelude_injection_pin` +Expected: BOTH tests still PASS after the rewrite (semantics +preserved). + +- [ ] **Step 5: Run the full surface crate test suite** + +Run: `cargo test -p ailang-surface` +Expected: all tests green. The rewrite is semantically invariant — the +shim and the new composition produce identical results. + +--- + +### Task 4: Re-migrate the 11 in-mod core tests + delete 1 + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs`'s `#[cfg(test)] mod tests` + block — re-migrate 10 tests to `surface::load_workspace`, re-migrate 1 + test to `load_modules_with` directly, delete 1 test. + +The shim disappears in Task 5; the in-mod test callers must already +have moved off it before the deletion. + +- [ ] **Step 1: Re-migrate the 9 fixture-style tests to `surface::load_workspace`** + +For each of the following 9 tests (lines per recon), replace +`load_workspace_with(&entry, load_one)` with `ailang_surface::load_workspace(&entry)`: + +| Line | Test fn | +|------|---------| +| 1693 | `detects_import_cycle` | +| 1709 | `module_not_found_yields_structured_error` | +| 1791 | `class_param_in_applied_position_fires_canonical_form_rejection` | +| 1814 | `superclass_with_wrong_param_fires_invalid_superclass_param` | +| 1854 | `constraint_with_unbound_var_fires_unbound_constraint_type_var` | +| 2491 | `ct1_fixture_bare_xmod_rejected` | +| 2507 | `ct1_fixture_bad_qualifier` | +| 2530 | `ct1_fixture_qualified_class_orphan_post_mq1` | + +Plus the reservation test at line 1675 (`user_module_named_prelude_is_rejected`) +— its assertion is unchanged because the variant tag preserves; only +the call site changes. + +Add at the top of the `mod tests` block (or near other `use` lines): + +```rust +#[cfg(test)] +use ailang_surface::load_workspace as surface_load_workspace; +``` + +(Aliasing because `load_workspace` no longer exists in `core::workspace` +and an unaliased import would shadow nothing useful but reduces clarity +about which crate is being called.) + +For each migration, the literal change pattern is: + +Old: +```rust +let result = load_workspace_with(&entry, load_one); +``` + +New: +```rust +let result = surface_load_workspace(&entry); +``` + +The result type and assertion logic are unchanged. + +- [ ] **Step 2: Run tests after migration** + +Run: `cargo test -p ailang-core` +Expected: all tests green. The 9 + 1 migrated tests still PASS because +`surface::load_workspace`'s behaviour is identical to the shim's by +Task 3 Step 4. + +- [ ] **Step 3: Re-migrate `load_workspace_with_custom_loader_is_called` + to `load_modules_with` directly** + +Test at line 2559. Its purpose: verify the loader-injection contract +(custom `Fn(&Path) -> Result` is invoked). The contract +post-pd.2 belongs to `load_modules_with`'s loader parameter (not the +disappearing `load_workspace_with`). Migrate by: + +Old shape (approximate — verify actual at the line): +```rust +let called = std::cell::Cell::new(false); +let custom = |p: &Path| { + called.set(true); + load_one(p) +}; +let _ = load_workspace_with(&entry, custom); +assert!(called.get()); +``` + +New shape: +```rust +let called = std::cell::Cell::new(false); +let custom = |p: &Path| { + called.set(true); + crate::load_module(p).map_err(|e| match e { + crate::Error::Io(io) => WorkspaceLoadError::Io { + path: p.to_path_buf(), + source: io, + }, + other => WorkspaceLoadError::Schema { + path: p.to_path_buf(), + source: other, + }, + }) +}; +let _ = load_modules_with(&entry, &custom); +assert!(called.get()); +``` + +The inline closure replaces `load_one`; the closure body wraps +`crate::load_module` (the JSON-only single-module loader, still public +in core) and adapts its error type. + +Test renames from `load_workspace_with_custom_loader_is_called` to +`load_modules_with_custom_loader_is_called` and the assertion changes +target accordingly. Same semantic intent (loader-injection contract is +honoured). + +- [ ] **Step 4: Delete `load_workspace_with_shim_preserves_today_semantics`** + +Test at line 2926. Its subject (the pd.1 shim) is being deleted in +Task 5. The test has no remaining purpose; delete it outright. + +If the test is bracketed by `// ---` separators or similar comment +markers, delete the markers too. + +- [ ] **Step 5: Run tests after both migrations + deletion** + +Run: `cargo test -p ailang-core` +Expected: all tests green. 11 in-mod test callers either moved or +removed; the rest of the test suite is unaffected. + +--- + +### Task 5: Delete `PRELUDE_JSON`, `load_prelude`, `load_workspace_with`, `load_one`, `load_module` test-only import + +**Files:** +- Modify: `crates/ailang-core/src/workspace.rs` — five deletions + (constant, fn, fn, fn, import). + +After Tasks 1-4, the only consumers of these symbols are gone. This +task is the destructive cleanup. + +- [ ] **Step 1: Verify no remaining callers** + +Run each of these greps; each must return nothing: + +```bash +# PRELUDE_JSON in production: +git grep -n 'PRELUDE_JSON' crates/ailang-core/src/ + +# load_prelude callers (production): +git grep -n 'load_prelude' crates/ailang-core/src/ + +# load_workspace_with callers (production OR test): +git grep -n 'load_workspace_with' -- + +# load_one callers: +git grep -n 'load_one' crates/ailang-core/ + +# crate::load_module test-only import (the one gated by cfg(test) in +# pd.1.5): +git grep -n '#\[cfg(test)\]' crates/ailang-core/src/workspace.rs | head -20 +``` + +If any production hit appears for the first three (excluding the +definition site itself in `crates/ailang-core/src/workspace.rs`), +**STOP** — Task 4 Step 1-2 missed a callsite, find it, migrate, re-run +the grep. + +- [ ] **Step 2: Delete `PRELUDE_JSON` constant** + +Delete the const declaration block in `crates/ailang-core/src/workspace.rs` +(post-pd.1 location ~lines 425-430 — verify with grep). Includes the +doc-comment paragraph immediately above. + +- [ ] **Step 3: Delete `load_prelude` fn** + +Delete the fn block (post-pd.1 location ~lines 432-439 — verify with +grep). Includes the doc-comment paragraph immediately above. + +- [ ] **Step 4: Delete `load_workspace_with` shim fn** + +Delete the entire fn block (post-pd.1 location ~lines 525-561 — verify +with grep). Includes the doc-comment block at lines 525-540 (the pd.1 +shim documentation). + +- [ ] **Step 5: Delete the now-orphaned `load_one` fn** + +Find `#[cfg(test)] fn load_one` in `workspace.rs` (~line 1598-1611 +per recon). Delete the entire fn including its `#[cfg(test)]` +attribute and any preceding doc-comment. + +- [ ] **Step 6: Delete the now-orphaned `crate::load_module` test-import** + +Find the `#[cfg(test)] use crate::load_module;` line at the top of +`workspace.rs` (added in pd.1.5). Delete it — it has no remaining +test consumer because the migrations in Task 4 Step 3 use +`crate::load_module` via fully-qualified path inside the test body, not +via top-level import. + +If a test added in Task 4 Step 3 still uses an unqualified `load_module`, +either re-add the import (gated `#[cfg(test)]`) or change the test to +fully-qualified `crate::load_module`. Prefer the latter for +locality. + +- [ ] **Step 7: Run the full workspace test suite** + +Run: `cargo test --workspace` +Expected: all tests green. If a compile error surfaces a missed +callsite, find it via the error's file:line, re-migrate. + +- [ ] **Step 8: Verify zero literal-`"prelude"` strings in `core/src/`** + +Run: `git grep '"prelude"' crates/ailang-core/src/` +Expected: NO matches. (The variant declaration at line 321-326 uses +no literal because the `name` field is `String` — it carries no +hardcoded string.) + +If any match appears, identify what it is and decide: +- Test fixture string (in `mod tests`) — fine to keep, it's not + production code; verify it's inside `#[cfg(test)]`. +- Production-code reference — STOP and find why; this violates the + milestone's primary acceptance criterion. + +- [ ] **Step 9: Update core's module rustdoc** + +Edit `crates/ailang-core/src/workspace.rs:1-32`. The pd.1 framing +("the pd.1 single core entry point is `load_workspace_with`") is now +stale. Replace with: + +```rust +//! Workspace loading and per-workspace validation. +//! +//! Two phases, exposed as separate public fns so callers can compose +//! them around an injection point (e.g. surface's prelude inject): +//! +//! - [`load_modules_with`] — DFS over `imports`, returns a modules +//! map with no validation and no implicit modules. The caller +//! supplies a per-module loader. +//! - [`build_workspace`] — runs the three-stage validation pipeline +//! (`validate_canonical_type_names`, `validate_classdefs`, +//! `build_registry`) plus `Workspace` assembly. Takes +//! `implicit_imports: &[&str]` so the diagnostic helpers can name +//! modules that exist but are not in any user import list. +//! +//! The public composition lives in `ailang-surface`'s `load_workspace` +//! (which adds a prelude inject step between the two phases). Direct +//! consumers of these core fns are `ailang-surface` and a handful of +//! tests; CLI dispatch goes through surface. +//! +//! This module is responsible only for **finding**, **assembling**, +//! and **schema-validating** the module graph. Cross-module +//! typechecking lives in `ailang-check`. Cross-module monomorphisation +//! and codegen live in `ailang-codegen`. +//! +//! [`module_hash`] is the module-granularity counterpart to +//! [`crate::def_hash`]; used internally to detect a re-loaded module +//! with different content, and by the CLI for stable per-module +//! identifiers. +``` + +- [ ] **Step 10: Update `lib.rs` rustdoc references** + +Edit `crates/ailang-core/src/lib.rs`. Three sites (per recon): + +- Lines 16-21 (module rustdoc preamble) — same shift in framing as + workspace.rs above. Reword to name the two-phase composition and + point at `ailang_surface::load_workspace` for the public entry. +- Lines 50-51 (further module rustdoc) — replace + `workspace::load_workspace_with` reference with the appropriate + successor (`load_modules_with` for "transitive loader" semantics or + `build_workspace` for "workspace assembly" — read the surrounding + paragraph to pick). +- Line 120 (doc-comment on `pub fn load_module`) — the + `for the transitive closure use workspace::load_workspace_with` + hint becomes + `for the transitive closure use [`ailang_surface::load_workspace`] + (which composes `workspace::load_modules_with` + a prelude inject + + `workspace::build_workspace`)`. + +These are mechanical reword edits; no semantic change. + +- [ ] **Step 11: Re-run cargo test --workspace + cargo doc** + +Run: `cargo test --workspace && cargo doc --no-deps -p ailang-core -p ailang-surface 2>&1 | grep -E '(warning|error)' | head -20` +Expected: tests green; doc warnings either nil or unchanged from the +pre-pd.2 baseline (16 pre-existing warnings per the roadmap; pd.2 +should not add new ones). + +--- + +### Task 6: Bench regressions + final acceptance + +**Files:** none directly. + +- [ ] **Step 1: Run regression baselines** + +Run: `python bench/check.py && python bench/compile_check.py && python bench/cross_lang.py` +Expected: all three exit 0. pd.2 changes are inside the +workspace-loader pipeline only; no codegen, no runtime, no +typecheck-result deltas. + +If any baseline regresses, investigate before declaring DONE — the +shape of the change should not move bench numbers. + +- [ ] **Step 2: Confirm pd.2 acceptance** + +Per the parent spec (§Acceptance criteria), pd.2's portion is: +- Item 3 (`git grep '"prelude"' crates/ailang-core/src/` returns no + matches) — verified at Task 5 Step 8. +- Item 4 (`cargo test --workspace` green, including the new pins) — + verified at Task 5 Step 7 + Task 6 Step 1. +- Item 5 (bench baselines green) — verified at Task 6 Step 1. + +Items 1, 2, 6, 7 belong to pd.3 (delete `prelude.ail.json`, the +`include_str!` audit, the carve_out_inventory + form-a §C4 (b) +spec edits, and the CLAUDE.md / DESIGN.md spot-check). + +--- + +## Out of scope for pd.2 + +The following spec items are deferred to pd.3 and MUST NOT be touched +in pd.2: + +- Deleting `examples/prelude.ail.json`. The cross-form-identity + preflight test in Task 2 explicitly depends on the JSON file being + present. +- Deleting the `prelude_ail_and_json_parse_to_identical_module` test + fn from `prelude_module_hash_pin.rs` (pd.3 work, after the JSON file + is gone). +- Deleting the defensive `include_str!` of `prelude.ail.json` in + `crates/ail/src/main.rs:472-484` (pd.3 work). +- Updating `crates/ailang-core/tests/carve_out_inventory.rs` to reflect + §C4 (b) becoming empty (pd.3 work). +- Updating `docs/specs/2026-05-13-form-a-default-authoring.md` §C4 (b) + (pd.3 work). +- Creating `prelude_decouple_carve_out_pin.rs` (pd.3 work — asserts + the JSON file does not exist; pd.2-time the file IS still present). + +A pd.2 implementer about to edit any of the above MUST stop and +re-read the parent spec's iteration sketch.