ADR 0117: Splitting beamtalk-core into Sub-Crates

Status

Accepted (2026-08-30)

Context

beamtalk-core is 260,210 lines — by far the largest crate in the workspace (all other crates combined are smaller). It came up as a side question while executing BT-3323 (the Rust-coverage epic): would splitting it into sub-crates help with build times, compile-time module boundaries, or general navigability?

docs/development/architecture-principles.md §1 already documents an aspirational internal layering for beamtalk-core:

│ beamtalk-core (library)             │  ← Compiler core (reusable)
│  ├─ queries/     (Language Service) │
│  ├─ parse/       (Lexer, Parser)    │
│  ├─ analyse/     (Semantic Analysis)│
│  └─ codegen/     (Core Erlang gen)  │

with a stated rule: codegen depends on parse; queries depends on all three; nothing depends upward. That diagram describes a clean DAG. This ADR checked whether the codebase still matches it, since if it does, extracting each layer into its own crate is close to mechanical.

What the real dependency graph looks like

Per-module line counts (find <dir> -name '*.rs' | xargs wc -l) and cross-module use crate::<module> edges, extracted directly from the source (not from the design doc):

ModuleLinesDepends on (real, as of this session)
codegen90,631ast, method_source_walker, repl, semantic_analysis, source_analysis, test_helpers, unparse
semantic_analysis95,668ast, ast_walker, compilation, source_analysis, state_threading_selectors, test_helpers, unparse
source_analysis25,658ast, method_source_walker, test_helpers, unparse
unparse5,612ast, codegen, source_analysis
ast4,522source_analysis, test_helpers
compilation1,772ast, source_analysis, test_helpers
repl1,246ast, codegen, source_analysis
queries16,812ast, compilation, language_service, method_source_walker, semantic_analysis, source_analysis, unparse
language_service7,560ast, compilation, semantic_analysis, source_analysis, test_helpers
lint3,928ast, ast_walker, semantic_analysis, source_analysis
project412file_walker, test_helpers only

Bold entries are the edges that don't fit the documented layering. Tracing them by hand:

Tracing reachability through the production-only edges above (dropping codegenrepl and source_analysisunparse, both test-only): codegen depends on semantic_analysis, which depends on compilation, which depends on source_analysis; unparse depends on source_analysis and codegen depends on unparse (native_facade.rs/methods.rs) while unparse depends on codegen (Document/leaf). That alone is a production cycle: {ast, source_analysis, unparse, codegen, semantic_analysis, compilation}.

Correction (post-review): the module-dependency table above was built by grepping use crate::<module> paths only and does not distinguish production code from #[cfg(test)]/#[test] code, and it undercounts real edges even within that scope. Verifying it against the source directly during review turned up three real, production edges the table omits entirely:

Since the ADR's own table already lists queries as depending on semantic_analysis and on language_service, these three findings close real two-way production cycles: queries ⇄ semantic_analysis and queries ⇄ language_service. Neither is test-only, so — unlike the codegenrepl and source_analysisunparse edges — neither can be waved off as a dev-dependency (Cargo does permit cyclic dev-dependencies within a workspace; it does not permit them in ordinary [dependencies]). queries is not acyclic with respect to the SCC, and language_service is not acyclic with respect to queries. This invalidates the "genuinely acyclic … 28,712 lines" claim this section originally made. lint and project are not implicated by these three findings and still appear to be one-way leaves. The Decision below has been rewritten around this corrected graph, framed by DDD bounded context rather than by Rust module.

The remaining ~6,400 lines are shared leaf modules (ast_walker, ffi_receiver, method_source_walker, state_threading_selectors, test_helpers, file_walker, ffi_type_specs, erlang, synthetic_selectors, tool_expr) consumed across nearly every module above; only four of the ten are actually pub(crate) in lib.rs (ast_walker, ffi_receiver, method_source_walker, state_threading_selectors) — the other six (test_helpers, file_walker, ffi_type_specs, erlang, synthetic_selectors, tool_expr) are already pub. Several of them are also not pure leaves: erlang.rs reaches upward into codegen/repl and ffi_type_specs.rs reaches into codegen/semantic_analysis, both in genuine production code. (test_helpers.rs looked the same at first grep, but doesn't hold up under the same production-vs-test scrutiny applied elsewhere in this section: its only upward reference — semantic_analysis::class_hierarchy::DeclaredType — is inside pub mod test_support, gated #[cfg(any(test, feature = "test"))]; its apparent unparse reference is a doc comment, not code; it has no reference to codegen/repl at all. It's a pure leaf in production.) A crate split would still need a visibility audit, but starting from "6 of 10 already public, two genuinely depend upward" rather than "all 10 are crate-private, pure downward leaves."

What beamtalk-core's actual consumers use (post-review addition)

beamtalk-core has three consumers today: beamtalk-cli, beamtalk-lsp, beamtalk-mcp. Grepping each consumer crate's own source for beamtalk_core::<module> (i.e. what they use across the public-API boundary, not beamtalk-core's internal crate:: edges) gives an empirical answer to "is there a shared core beneath the compiler proper":

Modulebeamtalk-lspbeamtalk-mcpbeamtalk-cli
source_analysis, semantic_analysis, unparse, queries, language_service, project
lint
codegen (90,631 lines, ~35% of the crate)✓ (only)
repl✓ (only)

Neither beamtalk-lsp nor beamtalk-mcp references codegen or repl anywhere in their own source. Only beamtalk-cli does (it's the only consumer that actually compiles to Core Erlang / builds and runs code). So the shared-core boundary isn't just an abstract DDD label — it's already visible in how the three consumers actually use the crate: parse + analyze + unparse + query/lint is what LSP and MCP need; codegen (and its REPL-codegen sibling repl) is CLI-only.

The catch: today they get codegen anyway, transitively, through exactly one thread — unparse imports codegen::core_erlang::document::{Document, leaf} (the edge already flagged above), and semantic_analysis depends on unparse in production via class_hierarchy::class_info::format_default_value, which calls unparse::unparse_literal_display (declared_type.rs's own call to unparse::unparse_type_annotation_display is test-only, inside #[cfg(test)] mod tests — not a second production call site). So beamtalk-lsp/beamtalk-mcp currently pull in all of codegen despite never calling it. Extracting the Document API to a shared leaf (Decision step 4 below) isn't just intra-Compilation hygiene, then — it's the one fix that would let a future beamtalk-analysis-shaped crate exclude codegen entirely, which would be a real, measurable build-time/type-checking-scope win for LSP and MCP specifically (not for the CLI, which needs codegen regardless) — see the revised Consequences note.

Constraints

Decision

Do not attempt a full crate split now. Resolve the cross-context cycles first, and use this repo's own DDD-context vocabulary — Language Service, Compilation, Runtime, REPL (CLAUDE.md § Architecture) — as the split boundary, not the internal Rust module list the original version of this Decision used.

Why bounded contexts, not modules

The corrected dependency graph (Context, above) contains two qualitatively different kinds of cycle:

  1. Cycles within the compiler pipelineastsource_analysis, unparsecodegen, plus semantic_analysis/compilation calling down into source_analysis. This is {ast, source_analysis, unparse, codegen, semantic_analysis, compilation} — 225,109 lines. Every module in it belongs to one DDD bounded context: Compilation. A cycle within one bounded context is internal cohesion, not an architecture violation — Rust mods inside a single crate are not required to be acyclic with each other, only crates are. None of this needs to move for a crate split to be possible; it can stay exactly one crate indefinitely.
  2. Cycles across what are supposed to be separate bounded contextsqueries ⇄ semantic_analysis and queries ⇄ language_service. queries and language_service are meant to be the Language Service context (architecture-principles.md's own diagram labels the layer queries/ (Language Service), and CLAUDE.md lists "Language Service" as a top-level DDD context) — a consumer of Compilation, not a peer entangled with it. These are the cycles that actually block a split, because they cross a context boundary.

The original version of this Decision (see git history) picked the wrong four cycles to fix first: three of the four (unparsecodegen's Document API, the REPL-test move, the source_analysis/unparse test move) are intra-Compilation or already test-only — good hygiene, but they were never what stood between this codebase and a Language-Service/Compilation split. The two cycles that actually matter (queries⇄semantic_analysis, queries⇄language_service) weren't in the original table at all. Revised plan:

  1. Add a CI-enforced module-dependency check. A script that fails CI when a new use crate::<module> edge crosses a declared bounded-context boundary in the wrong direction (Compilation → Language Service is fine; the reverse is not). This is the single highest-leverage step: it's what would have caught architecture-principles.md §1's diagram silently going stale in the first place — nothing noticed queries/semantic_analysis drifting into a cycle until this ADR's review dug for it by hand. Pays off whether or not any crate ever actually gets split, costs one script, and touches no production code. Size: S. Do this first, regardless of anything else below.
  2. Extract the modules that are genuinely one-way today: lint (3,928 lines) and project (412 lines) depend only on Compilation and each other, and nothing depends back on them in production. repl (1,246 lines) depends only on codegen/ast/source_analysis in production (its one back-edge, codegen's test files calling repl::codegen, is test-only). Together ~5,600 lines, ~2% of the crate — smaller than this ADR's original 11% estimate (which wrongly included queries/language_service), but real and low-risk today, no cycle-breaking required. Size: S-M.
  3. Break the two Language-Service ↔ Compilation cycles — the actual prerequisite for splitting anything bigger:
    • queries ⇄ semantic_analysis — one edge: semantic_analysis::type_checker::validation::check_arg_sendability (ADR 0103's process-boundary sendability check) calls queries::announce_sites_query::is_announce_selector. That function is a three-string membership check (pub(crate), no dependency on the rest of queries' AST-mining machinery) — a shared vocabulary fact ("which selectors mean this is an announce send"), not a query, needed by both a semantic check and a static-discoverability query. This crate already has the right pattern for exactly this situation: synthetic_selectors.rs, a pub, top-level leaf module whose own doc comment says "Keeping the selector-name computation here... makes this module the single source of truth and removes the drift risk." Move ANNOUNCE_SELECTORS/is_announce_selector into a leaf module of the same shape (new file, or folded into synthetic_selectors.rs if the author judges the theme close enough) and have both semantic_analysis and queries::announce_sites_query depend on it instead of one reaching into the other. Size: S.
    • queries ⇄ language_service — extensive, both directions, and not yet fully inventoried (this review sampled it, not exhaustively — needs a spike before sizing). The sampled shape: language_service (the LSP-facing orchestrator — file/project indexing, ProjectIndex) calls into queries for actual query behavior (check_native_delegate, find_selector_send_sites, find_implementors, ...) — the expected direction, Language Service's orchestrator consuming its own query implementations. Going the other way, queries' provider modules (completion_provider, definition_provider, document_symbols_provider, hover_provider, ...) import plain result/protocol types defined in language_service (Position, Location, Completion, DocumentSymbol, HoverInfo, ByteOffset). Two options, in order of preference:
      • (a, recommended default) Merge queries and language_service into one module. They are already one DDD context per CLAUDE.md; splitting that context into two Rust modules with no enforced boundary between them is very plausibly why they drifted into an unnoticed cycle. Merging removes the cycle by construction and matches the DDD-context framing this Decision is built on.
      • (b) If the author has a specific reason to keep them separate (e.g. wanting queries usable standalone by a non-LSP consumer — the MCP search tool in ADR 0062 is the closest candidate, worth checking whether it actually needs queries without language_service), extract the shared protocol types into a leaf module beneath both, and audit that no other queries → language_service behavioral call remains beyond type usage. Size: M, pending the spike.
  4. Do the remaining intra-Compilation hygiene fixes whenever convenient — none of these block a Language-Service/Compilation split, so they're not on the critical path, but the first item has a second justification beyond hygiene (see Context, "What beamtalk-core's actual consumers use", and Consequences/Neutral): it's the one thing standing between beamtalk-lsp/beamtalk-mcp and never having to compile codegen at all, so consider prioritizing it ahead of the other three:
    • Extract codegen::core_erlang::document (Document/leaf/docvec!) into its own crate (e.g. beamtalk-cerl-doc), not just an internal leaf module — a real crate boundary enforces the split at compile time rather than by convention, and it's the first physically separable piece of any eventual larger split (see the refinement note under step 5). Checked during review: document/ (1,090 lines) reaches back into codegen for exactly two things, both easily freed — escape_atom_chars/escape_core_erlang_string (util.rs, ~20 lines) and CoreErlangGenerator::binary_string_literal/binary_byte_segments (gen_server/spawn.rs; binary_string_literal is pub(crate), binary_byte_segments is pub(in crate::codegen::core_erlang) — narrower, but both static with no &self, so both move cleanly). All three move into the new crate together, ~1,150 lines total, with zero remaining dependency on the rest of codegen. unparse and codegen both depend on the new crate; neither depends on the other for this anymore. Size: M (touches every use crate::codegen::core_erlang::document::* call site, but the extraction itself is small and self-contained). (Leaves codegen's own one-way dependency on unparseunparse_method_display_signature etc. — untouched; that edge doesn't need fixing.)
    • Move the REPL-codegen test cases out of codegen's test tree into repl's. Size: S. (Hygiene only — Cargo permits cyclic dev-dependencies, so this was never split-blocking.)
    • Extract source_analysis::Span (+ needed parser item) to a shared leaf beneath ast and source_analysis. Size: S-M.
    • Move source_analysis's unparse-round-trip tests into unparse's test tree. Size: S. (Also hygiene only, for the same dev-dependency reason.)
  5. Split at the bounded-context boundary once step 3 lands: beamtalk-compilation (the current SCC, ast/source_analysis/unparse/codegen/semantic_analysis/compilation, ~225k lines, staying one crate — no further internal splitting proposed, see Consequences on why parallel-build payoff is low for a linear pipeline regardless) beneath beamtalk-language-service (merged queries+language_service, ~24k lines, per option 3a) and beamtalk-repl (~1.2k lines), with lint/project already extracted in step 2. Re-run the dependency-graph extraction first to confirm — by reading production vs. test call sites directly, not by grepping use crate:: paths, which is what produced the wrong graph in the first place (see Context).
    • Refinement worth costing out at that point, given the consumer-usage evidence in Context: once step 4's Document-API extraction lands, codegen (90,631 lines) has no production dependents left except beamtalk-cli and beamtalk-repl's own codegen helper. A beamtalk-codegen crate separate from the rest of Compilation (ast/source_analysis/semantic_analysis/unparse/compilation) would let beamtalk-lsp/beamtalk-mcp depend on the analysis crate without codegen at all — a real build-time/type-checking-scope win for those two, unlike the rest of this split (see Consequences/Neutral). Whether this is worth doing alongside or separately from the Language-Service extraction is a sizing question for whoever executes this step, not decided here.

Prior Art

Large Rust compilers commonly split into many crates for exactly the boundary-enforcement reason this ADR's Decision leans on, not primarily for parallel-build speed on a sequential pipeline:

Both are evidence that the target shape (once the cycles are fixed) is a well-trodden pattern for compiler workspaces this size — not evidence that skipping the cycle-breaking prerequisite is safe. Neither is a direct parallel for the "should we do this now" question this ADR answers, since both are rustc/rust-analyzer-scale projects with dedicated teams; the closer comparison for when to pay this cost is this repo's own architecture-principles.md §1, which already chose "document only" enforcement for the workspace's existing binary/library boundary on the grounds of solo-developer scale.

User Impact

This is an internal Rust-workspace-organization decision, not a Beamtalk language-design one, so the usual newcomer/Smalltalk-developer/Erlang-developer/operator personas from other ADRs don't apply — nothing here is visible from the REPL or from Beamtalk source code. The relevant stakeholders instead:

Steelman Analysis

Alternative A (full split now) — the argument for it

A compiler maintainer optimizing for the end state would argue: doing the cycle-breaking and the split together is one round of review and one round of regression-testing instead of two, and the cross-context cycle fixes alone (Decision step 3) don't return any visible value until the split actually happens (step 5) — so why not finish the job? This is a real argument when the team has spare capacity and low risk-aversion; it's weaker here because codegen carries the ThreadedIr verify() invariants CLAUDE.md flags as the codebase's most safety-critical, best-tested area, and the step-4 hygiene fixes touch it directly — exactly where "two changes in one review" is the more expensive way to find a regression, not the cheaper one.

Alternative B (no action) — the argument for it

An operator or a maintainer wary of process overhead would argue: architecture-principles.md §1 already decided "document only, code review sufficient" is enough enforcement for a much simpler boundary (binary crates vs. beamtalk-core) at solo-developer scale — extending that same judgment to internal modules within beamtalk-core is consistent, and the lint/project/repl extraction (Decision step 2) is arguably solving a problem no one has actually reported (compile times, navigability pain) rather than a problem this investigation set out to find. This is a fair challenge to step 2 specifically: it's a low-cost, low-risk change, but "low-cost" isn't the same as "requested" — worth the maintainer confirming compile-time/navigability pain is real before spending even the modest effort step 2 needs. It's a weaker challenge to step 1 (the CI check) and step 3 (the cross-context cycle fixes): both pay down a real, already-drifted architecture violation regardless of whether anyone ever asked for a crate split.

Tension point: the honest case for doing nothing at all (Alternative B) is stronger than the case for doing everything now (Alternative A) — this ADR's Decision sits deliberately between them, and a maintainer who agrees with B's skepticism should treat Decision step 2 (the lint/project/repl extraction) as optional rather than drop steps 1 and 3 (the CI check and the cross-context cycle fixes, which pay down a documented principle violation regardless of whether any split ever happens).

Alternatives Considered

A. Full split now, module-by-module, cycle-breaking included in the same effort

Break all cycles (originally scoped as four; corrected during review to include the two cross-context ones) and split into ~8 module-shaped crates (beamtalk-ast, beamtalk-source-analysis, beamtalk-semantic-analysis, beamtalk-codegen, beamtalk-unparse, beamtalk-repl, beamtalk-compilation, beamtalk-language-service, beamtalk-queries, beamtalk-lint) in one XL effort.

Rejected for now: bundles the highest-risk work (editing codegen's Document API and source_analysis's parser, both load-bearing and heavily tested) with the split itself, so a regression is hard to attribute to "the refactor" vs. "the split." Also rejected on its own terms once the graph was corrected: splitting one-module-per-crate ignores that most of these modules (ast/source_analysis/unparse/codegen/semantic_analysis/compilation) are one DDD bounded context (Compilation) with no reason to be separate crates — see Alternative D.

B. No action at all

Leave beamtalk-core as one crate indefinitely; treat this investigation as answering the question without committing to any follow-up.

Rejected: the CI dependency-direction check and the lint/project/repl extraction (Decision steps 1-2) are low-risk, low-effort, and deliver a real, if modest, boundary win — there's no reason to leave those on the table just because the bigger split isn't worth doing yet. Breaking the two cross-context cycles (step 3) also independently pays down a documented architecture-principle violation (§6) regardless of whether a crate split ever happens. (See Steelman Analysis above for the strongest version of this alternative's case.)

C. Merge codegen and unparse into one crate permanently, split everything else

Given how tightly unparse depends on codegen's Document/leaf API, one option is to treat them as permanently one unit (a beamtalk-codegen crate covering both Core-Erlang generation and Beamtalk-source pretty-printing) and only extract the other three cycle edges.

Not rejected — subsumed by Alternative D below: under the bounded-context framing, codegen and unparse are both inside the single beamtalk-compilation crate anyway, so they end up merged by default without needing to special-case it.

D. Split along DDD bounded contexts instead of Rust modules (adopted — see Decision)

Treat {ast, source_analysis, unparse, codegen, semantic_analysis, compilation} as one Compilation crate (matching CLAUDE.md's own DDD-context list) rather than trying to decompose it into ~7 module-shaped crates. Only split where a context boundary is crossed: beamtalk-compilationbeamtalk-language-service (merged queries+language_service) ← nothing (LSP/MCP consume it), plus beamtalk-repl and the already-independent lint/project.

This was not in the original version of this ADR — it emerged from this review's discovery that the two cycles blocking a split (queries⇄semantic_analysis, queries⇄language_service) are both cross-context while the four cycles the original Decision focused on fixing are mostly intra-context (three of four are inside Compilation; the fourth, queries⇄language_service, is intra-Language-Service). Adopted because it directly explains which cycles are architecturally significant (cross-context) versus which are just internal cohesion (intra-context, fine to leave as-is), which Alternative A/C's module-by-module framing did not distinguish.

Consequences

Positive

Negative

Neutral

Implementation

Phases mirror the Decision's five steps. 1-2 are independent and can land in either order; 3 doesn't require 1-2 but should follow the CI check (1) so the fix is guarded on landing; 4 is unordered hygiene; 5 requires 3 done first.

  1. CI dependency-direction check (Decision step 1): script + CI job asserting the declared bounded-context boundaries (Compilation → Language Service allowed; reverse forbidden). Size: S. Do first.
  2. Extract lint, project, repl (Decision step 2): move to 1-2 new crates atop beamtalk-core; fix up Cargo.toml edges; re-run full test suite. Size: S-M.
  3. Break the two cross-context cycles (Decision step 3), each its own PR:
    • queries⇄semantic_analysis: extract ANNOUNCE_SELECTORS/is_announce_selector to a shared leaf module (pattern: synthetic_selectors.rs). Size: S.
    • queries⇄language_service: spike first to inventory the full edge set (this review sampled ~10 call sites, not exhaustive), then either merge the two modules (recommended) or extract shared protocol types to a leaf. Size: M, pending the spike.
  4. Intra-Compilation hygiene fixes (Decision step 4), each its own PR, none split-blocking:
    • Extract codegen::core_erlang::document to a shared leaf module. Size: M (touches every use crate::codegen::core_erlang::document::* call site).
    • Move REPL-codegen tests from codegen's test tree into repl's. Size: S.
    • Extract source_analysis::Span (+ needed parser item) to a shared leaf module. Size: S-M.
    • Move source_analysis's unparse-round-trip tests into unparse's test tree. Size: S.
  5. Bounded-context split (Decision step 5): once step 3 lands, re-run this ADR's dependency-graph extraction — verifying production vs. #[cfg(test)] for each edge, not grepping use crate:: paths only, which is what produced the original wrong graph (see Context) — to confirm the cross-context cycles are actually gone. Then extract beamtalk-compilation (the former SCC, staying one crate) and beamtalk-language-service (merged queries+language_service), informed by real profiling data on whether the payoff is worth it at that point (see Consequences, Neutral).

None of this is scheduled against a Linear epic yet — file issues under a new epic if the maintainer wants to act on this ADR, parented separately from BT-3323 (which this is out of scope for).

Implementation Tracking

Epic: BT-3338 Issues: BT-3339 (CI check), BT-3340 (lint/project/repl extraction), BT-3341 (queries⇄semantic_analysis fix, blocked by BT-3339), BT-3342 (queries⇄language_service fix, blocked by BT-3339), BT-3343 (Document API crate — done, extracted into beamtalk-cerl-doc), BT-3344 (REPL-codegen test move — done), BT-3345 (Span extraction), BT-3346 (unparse-round-trip test move) Status: Complete — all eight issues are Done.

Step 5 epic: BT-3359. The dependency-graph re-run this ADR required before step 5 was performed on 2026-08-31 (production vs. #[cfg(test)] edges verified directly, per the Implementation section's instruction). Findings: the cross-context cycles are gone; language_service (incl. the merged queries) has zero production references to codegen; the only remaining back-edges into codegen are two leaf-file edges (ffi_type_specs.rs's import of escape_erlang_string, and the erlang.rs re-export shim). The split — including the Decision step 5 refinement (a separate beamtalk-codegen crate, so beamtalk-lsp/beamtalk-mcp/beamtalk-lint stop compiling codegen entirely) — is tracked as BT-3360 (sever the two leaf back-edges), BT-3361 (extract beamtalk-language-service), BT-3362 (extract beamtalk-codegen, blocked by BT-3360/BT-3361), and BT-3363 (simplify beamtalk-boundary-check to what cargo can't enforce, measure the LSP/MCP build delta, and record beamtalk-core's fate — facade vs. rename to beamtalk-compilation — back into this ADR).

beamtalk-boundary-check retirement (BT-3363): by the time BT-3362 landed, queries, language_service, lint, and codegen no longer existed as directories under beamtalk-core/src at all — all four had been extracted into standalone crates (beamtalk-language-service, beamtalk-lint, beamtalk-codegen) whose Cargo.toml dependency direction (downward-only onto beamtalk-core) is enforced by Cargo itself: a reverse edge is a cyclic-package-dependency compile error, not something a separate checker needs to catch by parsing source. The remaining Compilation modules (ast, source_analysis, unparse, semantic_analysis, compilation) are, per this ADR's own Decision ("Cycles within the compiler pipeline"), free to depend on each other — internal cohesion within one bounded context, not a boundary this ADR restricts — so there was no intra-crate rule left for check-boundary to enforce either. Verified directly: check_module_list_drift's own module lists (COMPILATION_MODULES/LANGUAGE_SERVICE_MODULES/OTHER_MODULES) already documented queries/language_service/lint as "regression guards" only, and codegen had already been dropped from the list entirely by BT-3362. The crate (crates/beamtalk-boundary-check, ~1,511 lines), its just check-boundary recipe, and its CI step were removed outright rather than reduced — no rules survived that Cargo doesn't already enforce. See docs/development/architecture-principles.md §1 Enforcement.

Build-time measurement (BT-3363): real cargo build/cargo check timings for beamtalk-lsp and beamtalk-mcp, comparing commit ec08be29 (last commit before BT-3360, the pre-epic baseline — beamtalk-core still monolithic, containing queries/language_service/lint/codegen) against commit d44d5403 (BT-3362 merged, the post-epic state this PR builds on). Methodology: each crate/mode pair was measured from a fresh, empty CARGO_TARGET_DIR for the "clean" number (so external-dependency compilation is included, not just workspace crates); the same target directory was then reused, after touching beamtalk-core/src/span.rs (a leaf file both beamtalk-lsp and beamtalk-mcp depend on transitively) to force a real recompilation, for the "incremental" number. beamtalk-mcp was measured in the same warm target directory right after beamtalk-lsp, deliberately — this measures the marginal cost of also needing beamtalk-mcp once beamtalk-lsp's shared dependencies are already built, which is exactly the number this split is supposed to move (beamtalk-mcp still transitively needs beamtalk-codegen via its pre-existing beamtalk-cli dependency; beamtalk-lsp never did). One machine, 4 cores, single run per cell (not averaged over multiple runs) — treat these as directional, not lab-grade benchmarks:

before (ec08be29)after (d44d5403)Δ
cargo build -p beamtalk-lsp — clean64.36s56.15s−12.8%
cargo build -p beamtalk-lsp — incremental5.80s4.01s−30.9%
cargo build -p beamtalk-mcp — after lsp already built50.60s52.18s+3.1%
cargo build -p beamtalk-mcp — incremental6.71s5.59s−16.7%
cargo check -p beamtalk-lsp — clean39.61s35.54s−10.3%
cargo check -p beamtalk-lsp — incremental4.03s2.16s−46.3%
cargo check -p beamtalk-mcp — after lsp already checked31.38s33.39s+6.4%
cargo check -p beamtalk-mcp — incremental4.31s4.00s−7.3%

The data matches the prediction in Context/Consequences exactly: beamtalk-lsp gets a real, consistent win across all four of its cells (roughly −11 to −13% on the two clean numbers, and a much larger −31 to −46% on the two incremental numbers — touching one shared leaf file, span.rs, now invalidates a meaningfully smaller compilation unit than it did when beamtalk-core also carried codegen's ~90k lines). beamtalk-mcp shows no consistent win, as expected — its two "warm" cells (measuring the marginal cost of also building/checking beamtalk-mcp once beamtalk-lsp's shared deps are already compiled) are flat-to-slightly-up, not down, because beamtalk-mcp still transitively depends on beamtalk-codegen through its pre-existing beamtalk-cli dependency either way (see Context, "What beamtalk-core's actual consumers use"); the small +3.1%/+6.4% deltas there are within the noise of a single-run, 4-core measurement, not a regression the split caused. beamtalk-mcp's two incremental cells did improve slightly (−16.7%/−7.3%), consistent with span.rs now sitting in a smaller beamtalk-core even though beamtalk-codegen itself is unchanged. Net: this confirms the Consequences/Neutral note's prediction — the split is a real, measurable win specifically for beamtalk-lsp (and, by the same argument, beamtalk-lint), not for beamtalk-mcp or beamtalk-cli, which both need the full pipeline regardless of crate boundaries.

beamtalk-core's fate (BT-3363): kept as-is — the crate stays named beamtalk-core, is not renamed to beamtalk-compilation, and gets no new facade/re-export layer. It already is the shape ADR 0117's Decision (step 5) called beamtalk-compilation: exactly the former SCC (ast/source_analysis/unparse/semantic_analysis/compilation), one crate, with beamtalk-language-service and beamtalk-codegen sitting on top of it and nothing sitting inside it that shouldn't. Renaming it would be a purely cosmetic change — every consumer (beamtalk-cli, beamtalk-lsp, beamtalk-mcp, beamtalk-compiler-port, beamtalk-codegen, beamtalk-language-service, beamtalk-lint, plus every doc, test, and CI reference to the crate name) would need touching for it, and nothing about the crate graph, build boundary, or enforcement changes either way — Cargo already enforces the downward-only edge regardless of what the crate is called. Per BT-3363's own acceptance criteria — err toward the lower-risk, smaller-diff option absent a clear reason to rename — keep beamtalk-core. Revisit only if a future maintainer finds the name actively confusing (e.g. once/if a beamtalk-compilation-shaped further split ever happens for reasons unrelated to this ADR).

Status: Complete — BT-3360, BT-3361, and BT-3362 are Done; BT-3363 (this cleanup issue) closes out epic BT-3359 with the retirement, measurement, and decision recorded above. With it, this ADR's Decision step 5 is fully implemented, and the outstanding items this ADR's Consequences flagged as deferred (informed-by-real-profiling-data judgment on whether the split was worth it, and beamtalk-core's naming) are both resolved.

References