# Env-construction unify — Design Spec **Date:** 2026-05-10 **Status:** Draft — awaiting user spec review **Authors:** Brummel (orchestrator) + Claude ## Goal Eliminate the structural drift between `check_in_workspace` (`crates/ailang-check/src/lib.rs`, near line 1059) and `mono::build_workspace_env` (`crates/ailang-check/src/mono.rs`, near line 367) — two paths that construct the same `Env` shape and have repeatedly diverged under feature pressure. The empirical motivation is three consecutive bug fixes patching the same drift class at three different fields: | Commit | Field forgotten by mono path | Iter | |--------|------------------------------|------| | `5c5180f` | `env.types`, `env.ctor_index` | 22c | | `13b36cc` | `env.globals` | post-22 debug | | `a9c685d` | `env.imports` | post-22 audit | Each bug shipped as `ail check` passes / `ail build` fails with an env-table-miss in `monomorphise_workspace`. The pattern says a fourth field is one feature away. ## Architecture A single source-of-truth function ```rust pub fn build_check_env(ws: &Workspace) -> Env ``` in `crates/ailang-check/src/lib.rs` populates every workspace-flat `Env` field — that is, every field whose contents do not depend on a `current_module` overlay. Both existing entry points become thin consumers of this helper: - `check_in_workspace` calls `build_check_env(ws)`, then applies the per-module overlay for the module it is checking (`current_module`, `globals` from `module_globals[m.name]`, `imports` from `m.imports`). - `mono::build_workspace_env` is a thin wrapper: `pub fn build_workspace_env(ws: &Workspace) -> Env { build_check_env(ws) }`. No additional seeding lives here. The per-module overlays in `collect_mono_targets` and `collect_residuals_ordered` (added in `13b36cc` and `a9c685d`) are already correctly placed at the per-fn call sites — they are not duplicated by this milestone, they stay where they are. The split mirrors the semantic distinction: - **Workspace-flat fields** are derivable from `Workspace` alone and do not change per call. They go in `build_check_env`. - **Per-module / per-fn overlay fields** depend on the call's `current_module` (or rigid vars). They stay at the call site. After this refactor, when `synth` learns to read a new `Env` field, exactly one place needs the seeding edit. ## Components **Files modified:** - `crates/ailang-check/src/lib.rs` — adds `pub fn build_check_env(ws: &Workspace) -> Env`. Refactors `check_in_workspace` to call it and delete the now-duplicated inline seeding (effect_ops install, types, ctor_index, module_globals, module_types, class_methods, class_superclasses, workspace_registry, module_imports). `check_in_workspace` retains its per-module overlay logic (current_module, globals, imports for the module it is checking). - `crates/ailang-check/src/mono.rs` — `build_workspace_env` becomes a one-line wrapper around `build_check_env`. The drift-risk comment ("If `synth` starts reading a new `Env` field, update both paths") is deleted — there is no longer a pair of paths to keep in sync. **Files added:** - `crates/ailang-check/tests/env_construction_pin.rs` — a new integration test that pins the drift-shape invariant. See Testing strategy. **Fields seeded by `build_check_env` (workspace-flat):** - `effect_ops` — via `builtins::install` - `types` — workspace-flat from every `Def::Type` - `ctor_index` — workspace-flat from every `Def::Type`'s ctors - `module_globals` — per-module index built from `build_module_globals(ws)` - `module_types` — via `build_module_types(ws)` - `module_imports` — per-module index built from each `Workspace::modules.values().imports` - `class_methods` — workspace-merged from each module's `ModuleGlobals::class_methods` - `class_superclasses` — walk every `Def::Class` for `superclass` - `workspace_registry` — clone of `ws.registry` **Fields not seeded by `build_check_env` (per-call overlay, applied by callers):** - `current_module` - `globals` - `imports` - `rigid_vars` ## Data flow ``` ws: &Workspace │ ▼ build_check_env(ws) ─► Env (workspace-flat seeded) │ ├─► check_in_workspace(m: &Module): │ env.current_module = m.name │ seed env.globals from env.module_globals[m.name] │ build env.imports from m.imports │ run check on each Def in m │ └─► mono::build_workspace_env(ws): return build_check_env(ws) verbatim (callers apply per-fn overlay) │ ▼ collect_mono_targets / collect_residuals_ordered (per-fn): env.clone() env.current_module = module_name seed env.globals from env.module_globals[module_name] seed env.imports from env.module_imports[module_name] for v in fn rigids: env.rigid_vars.insert(v) run synth on body ``` One construction; two consumers; per-fn overlays at the per-fn call sites. ## Error handling No new error variants. The existing `build_module_globals(ws).expect(...)` call (used by `build_workspace_env` today) is a caller-contract panic that fires only if the mono pass is invoked on a workspace that has not passed typecheck — a programmer error, not a user error. That panic moves into `build_check_env` unchanged. `build_check_env` has no fallible operations: every seeding step is a `clone` + `insert` into `BTreeMap` / `IndexMap`, all infallible. User-facing diagnostics (`UnknownIdent`, `UnknownModule`, `UnknownType`, `UnknownCtor`) continue to fire at the same call sites in `synth`; the refactor changes the construction of `Env`, not its read paths. ## Testing strategy **Existing regression suite (drift class):** the three RED tests that pinned today's drift-class bugs run automatically and stay green: - `crates/ail/tests/typeclass_22c.rs` — covers `env.types` / `env.ctor_index` (5c5180f). - `crates/ail/tests/mono_recursive_fn.rs` — covers `env.globals` (13b36cc). - `crates/ail/tests/mono_xmod_qualified_ref.rs` — covers `env.imports` (a9c685d). If any of these regress after the refactor, the unify is broken and must not ship. **New drift-shape pin test (`crates/ailang-check/tests/env_construction_pin.rs`):** a single integration test that: 1. Builds a representative class-bearing workspace fixture covering at least one `Def::Class`, one `Def::Instance`, one user ADT, and one cross-module import. 2. Computes `env_a = build_check_env(&ws)` (the new helper). 3. Computes `env_b` by running the inline construction the way `check_in_workspace` did pre-refactor — captured by reading the same code path and reproducing it inline in the test. (Practically: a small in-test helper that mirrors the pre-refactor seeding so the assertion has something to compare against.) 4. Asserts that `env_a` and `env_b` agree on every workspace-flat field — same key-set, same per-key value (or, where types do not implement `PartialEq`, same key set). The test serves as a tripwire: any future change that adds a new workspace-flat seed to one path but not the helper fails the test before it can ship a real bug. **Bench gates:** `bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py` must each report 0 regressed. Performance of the refactor must be neutral — `build_check_env` is logically the same work as the pre-refactor inline. **Workspace test suite:** `cargo test --workspace` must remain fully green (~344 tests). ## Acceptance criteria 1. `pub fn build_check_env(ws: &Workspace) -> Env` exists in `crates/ailang-check/src/lib.rs`. 2. `mono::build_workspace_env` is a one-line wrapper: it calls `build_check_env(ws)` and returns the result. No inline seeding remains in this function. 3. `check_in_workspace` calls `build_check_env(ws)` instead of inline workspace-flat seeding. Per-module overlay logic (current_module, globals, imports for the module being checked) remains. 4. The drift-shape pin test (`crates/ailang-check/tests/env_construction_pin.rs`) is green. 5. The three existing RED tests (`typeclass_22c`, `mono_recursive_fn`, `mono_xmod_qualified_ref`) remain green. 6. `cargo test --workspace` is fully green. 7. `bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py` each report 0 regressed. 8. The `// Drift risk: this fn and `crate::check_in_workspace` both populate `crate::Env`. If `synth` starts reading a new `Env` field, update both paths.` comment in `mono.rs::build_workspace_env` is deleted (it no longer applies). ## Out of scope - Pipeline-topology changes (e.g. moving the mono pass inside `check_workspace`). The pass-decomposition invariant — mono is post-typecheck / pre-codegen — stays. - Refactoring the per-module overlay shape. The `current_module` / `globals` / `imports` / `rigid_vars` overlay is semantically correct as it is and stays at the call site. - Primitive-name-set consolidation (still queued from 22-tidy). Different drift class, different milestone. - New env fields. This milestone preserves the current shape; it only changes who constructs it.