ADR 0089: Typed Document Leaves for Core Erlang Codegen

Status

Implemented (2026-05-31). Long-term fix for the BT-875 recurrence vector, replacing the withdrawn Phases 1–4 of ADR 0088. All three phases have landed: the typed-leaf API (document::leaf, unparse::leaf) is the only path to a runtime-derived leaf, and the Phase 3 flag-day PR (BT-2331) removed the public Document::String / Document::Eco variants, structurally closing the BT-875 recurrence vector.

Implementation Tracking

Epic: BT-2319 — Typed Document Leaves Refactor (ADR 0089)

PhaseIssueDescriptionStatus
1BT-2320Add document::leaf API + migration lintDone
1BT-2321Add unparse::leaf API + migrate unparse/mod.rsDone
2BT-2322Migrate leaf utilities + small codegen filesDone
2BT-2323Migrate repl/codegen.rsDone
2BT-2324Migrate expressions.rsDone
2BT-2325Migrate intrinsics.rsDone
2BT-2326Migrate value_type_codegen.rsDone
2BT-2327Migrate dispatch_codegen.rsDone
2BT-2328Migrate gen_server/Done
2BT-2329Migrate control_flow/Done
2BT-2330Migrate mod.rs + remainingDone
3BT-2331Flag-day: remove Document::String and Document::EcoDone

Recommended start: BT-2320 (Phase 1, no dependencies).

Context

Why this ADR exists

ADR 0018 established the Wadler-Lindig Document tree as the only sanctioned way to construct Core Erlang output. The rule in CLAUDE.md is unambiguous:

All Core Erlang codegen MUST use Document / docvec! API. NEVER use format!() or string concatenation to produce Core Erlang fragments — not even for "simple" atoms, arities, or map keys.

That discipline holds at the combinator level. The renderer can no longer be bypassed; everything passes through Document. But the Document enum still has a typed escape hatch:

pub enum Document<'a> {
    Str(&'a str),
    String(String),   // ← BT-875 recurrence vector
    Eco(ecow::EcoString),
    Line, Nest(..), Vec(..), Group(..), Break {..}, Nil,
}

Document::String(...) accepts arbitrary text. An author who reaches for Document::String(format!("'{name}'")) or Document::String(name.replace('\'', "\\'")) produces well-formed combinator code that still ships a manually-rendered Core Erlang fragment. BT-875 is the running history of this class of bug. The CLAUDE.md prohibition catches it during review; the type system does not.

What ADR 0088 was going to do, and why it isn't

ADR 0088 proposed replacing the Document tree with a typed cerl::Expr AST shipped over the Port as ETF. That closes BT-875 structurally — there is no leaf that accepts a raw string — and unlocks annotation fidelity for downstream consumers (BT-aware stack traces, debugger support). The cost is a 58K-LOC migration across ~55 codegen files.

Phase 0 of ADR 0088 measured the trade-off:

Phase 0c's recommendation, accepted in PR #2352, withdrew ADR 0088 Phases 1–4 in favour of typed-Document-leaves. This ADR turns that recommendation into a buildable plan.

The BT-875 recurrence vector this refactor closes

Today an author can ship any of these:

docvec!["{'", Document::String(name), "', '", Document::String(super_), "'}"]
docvec!["call '", Document::String(module), "':'", Document::String(fun), "'"]
docvec!["let ", Document::String(var), " = ", body, " in ", ...]

// Same vector through Document::Eco — live today in actor_codegen.rs et al.:
docvec!["module '", Document::Eco(self.module_name.clone()), "' [", ...]
docvec!["'", Document::Eco(method.selector.name()), "'/", ...]

In each case the author has manually rendered atom-quote or variable-name punctuation around a typed-but-text-shaped leaf (String or Eco). The leaf carries no semantic information; the punctuation has to be re-typed at every site; mistakes are typos rather than type errors. BT-875's eight cleanup commits all share this shape.

Replacing the open String leaf with a typed-leaf API — atom(name), var(name), string_lit(s), int_lit(i), float_lit(f), fname(name, arity) — makes the leaf carry intent and makes the punctuation a property of the helper, not of the call site:

docvec!["{", atom(name), ", ", atom(super_), "}"]
docvec!["call ", atom(module), ":", atom(fun), "(", join(args, ", "), ")"]
docvec!["let ", var(name), " = ", body, " in ", ...]

Document::String is then removed from the enum, the BT-875 vector becomes unrepresentable, and the renderer is unchanged.

Out-of-tree consumers of Document

The Document enum is also used by ~2 modules outside the codegen/core_erlang/ tree:

The Beamtalk-side unparser nonetheless depends on the Documentable<'a> for String impl. To avoid breaking it during Phase B, the unparser migrates to construct its Document<'static> leaves through a small parallel API (unparse::leaf) that wraps Document::Str for compile-time fragments and builds the (nominally pub) Document::Owned(String) variant for runtime-derived text. The unparse-leaf API is intentionally separate from document::leaf because the escaping rules differ (no atom-quote ceremony, no Core Erlang string escaping). Implementation epic must schedule unparse migration before the Documentable<'a> for String impl is removed.

Document::Eco — the sibling escape hatch

Document::Eco(ecow::EcoString) carries the same BT-875 risk as Document::String. There are ~57 production Document::Eco(...) call sites in the codegen tree, used primarily for module names and selector names passed from the AST as EcoString values. The migration must cover both variants.

Document::Str(&'static str) is not at risk. Its ~467 uses are compile-time string literals (fixed Core Erlang fragments like "\n", ", ", "'dispatch'/3"). An author cannot inject dynamic content through a &'static str — the Rust type system prevents it. Str remains as the only text-leaf variant after the migration.

Constraints

Decision

Replace Document::String and Document::Eco with a typed-leaf API in a single flag-day migration, then remove both variants from the enum.

The seven decisions called out by BT-2318:

1. Leaf variant set

Seven helpers, all returning Document<'static>:

HelperRenders asExample call site today
atom(name)'name' (quoted Core Erlang atom, escaped)Document::String(class.name()) inside atom-quotes
var(name)VarName (bare Core Erlang variable)Document::String(var_name.clone())
string_lit(s)"escaped string" (escaped Core Erlang string literal)Document::String(escape_core_erlang_string(s))
int_lit(i: i64)integer literalDocument::String(n.to_string())
float_lit(f: f64)float literal in Core Erlang formrare; today via Document::String(format!("{:?}", f))
fname(name, arity)'name'/arity (function-name / arity pair for remote calls)docvec!["'", Document::String(fun), "'/", Document::String(arity.to_string())]
binary_lit(s)Core Erlang binary syntax (#{#<65>(8,...), ...}#) for a UTF-8 stringDocument::String(Self::binary_string_literal(s)) (~11 production sites)

The set is grounded in the survey of ~2,300 Document::String(...) call sites across the codegen tree: dominantly variable names and atom names, with a long tail of literal numbers, strings, function-arity pairs, and binary literals. The binary_lit helper was added after the initial audit surfaced ~11 Document::String(Self::binary_string_literal(...)) sites in gen_server/methods.rs, expressions.rs, and gen_server/callbacks.rs — the audit prototype's Document::String-only grep missed them because the binary_string_literal call hides the leaf shape.

Escaping contract. atom(name) and string_lit(s) are responsible for their own escaping — atom calls escape_atom_chars internally (handling '\' and \\\), string_lit calls escape_core_erlang_string. Callers pass raw text. This contract is load-bearing: it removes the "did I remember to escape this?" decision from every call site and concentrates it in the helper. Unit tests for each helper must cover non-trivial inputs (e.g. atom("it's").to_pretty_string() == "'it\\'s'").

Text-valued helpers (atom, var, string_lit, fname) accept impl Into<String> to match the audit prototype's ergonomics. A &str-taking overload may be added during migration if the .clone() overhead is visible in compile-time profiles, but is not part of the baseline API. int_lit takes i64 (or a wider integer type) and float_lit takes f64, so callers cannot pass arbitrary strings through the numeric helpers.

2. Where the API lives

A new submodule document::leaf, accessed as:

use crate::codegen::core_erlang::document::leaf::{atom, var, string_lit, int_lit, float_lit, fname, binary_lit};

Rejected alternatives:

3. Document::String and Document::Eco deletion strategy

Remove from public API, flag-day migration.

A single PR adds the document::leaf helpers, migrates all ~2,360 call sites mechanically, and removes Document::String and Document::Eco from the public enum. The Document::Owned(String) variant survives inside the document module as the internal backing for the typed-leaf helpers. Rust cannot give a variant a narrower visibility than its enum, so Owned is nominally pub; the structural closure comes from deleting the String/Eco variants and the Documentable for String/EcoString impls, leaving the typed-leaf constructors as the only path that builds it. No deprecation window, no parallel paths.

This is the most aggressive of the three options the issue called out and is chosen for two reasons:

Trade-off: one large PR carries a heavier review burden than a series of smaller ones. Mitigated by (a) the mechanical nature of the edit (reviewers verify the variant classification, not the codegen correctness), (b) the existing ~245 codegen unit tests asserting on to_pretty_string() output (the byte-for-byte parity check is automatic), and (c) the full behavioural suite (just test-stdlib/test-bunit/test-repl-protocol) verifying end-to-end equivalence.

4. Migration ordering

The ADR specifies only the principle. The implementation epic (via /plan-adr) decides per-PR sequencing.

Principle: leaf-first by call-site density, batched per file or small subdirectory so each batch is reviewable independently. Files with high Document::String density (control_flow/list_ops/transform_ops.rs at 438 sites, control_flow/mod.rs at 206) are individually large enough to warrant their own batches; small files (operators.rs at 7, spec_codegen.rs at 7) can be combined.

The flag-day-deletion constraint means the final PR in the series removes Document::String only after every site has migrated. The epic's sequencing must end with a "remove variant" PR that no longer has any unmigrated call sites to address.

5. BT-875 closure proof

Structural after Phase B — the public-API variants cease to exist.

Today, the BT-875 vector is open through both Document::String and Document::Eco. The Document::Eco(self.module_name.clone()) pattern embedded between '...' atom-quotes is currently live in multiple files (notably actor_codegen.rs and dispatch_codegen.rs) and is structurally identical to the Document::String(format!("'{name}'")) shape that BT-875 catalogs. The CLAUDE.md rule + review process catch new instances; the type system does not prevent them.

Once Phase B lands and Document::String and Document::Eco are removed from the public Document enum, no codegen call site constructs an arbitrary-text leaf. The internal Document::Owned(String) variant survives as the backing for the typed-leaf helpers and is built only inside them (document::leaf for Core Erlang, unparse::leaf for Beamtalk source). Rust does not permit an enum variant to have a narrower visibility than its enum, so Owned is nominally pub; the structural closure comes from deleting the Document::String / Document::Eco variants and the Documentable for String / EcoString impls, which removes every ergonomic path ("…".to_string().to_doc(), docvec![owned_string]) to an open leaf. The CLAUDE.md codegen rule records the convention that Owned is built only inside the leaf helpers.

Document::Str(&'static str) remains public, but it accepts only compile-time string constants. An author cannot pass a runtime-computed &str to Document::Str without unsafe lifetime extension, which Rust's borrow checker prevents. The BT-875 recurrence vector is unrepresentable through normal code; the CLAUDE.md prohibition becomes redundant (kept as documentation of intent).

Two secondary defences:

The flag-day removal makes the primary defence structural; the lint and the CLAUDE.md rule become belt-and-braces.

6. Annotation extensibility (for BT-aware stack traces)

Sketched here; full design deferred to a sibling ADR scheduled with the BT-aware stack-traces consumer.

Sketch. Add a future sibling variant to Document:

pub enum Document<'a> {
    // existing variants...
    Annotated(Annotation, Box<Document<'a>>),
}

pub struct Annotation {
    pub file: Option<EcoString>,
    pub line: Option<u32>,
    pub column: Option<u32>,
    // extensible
}

The pretty-printer renders Document::Annotated(ann, inner) as just inner (the annotation is transparent to text output). A second annotation-collecting renderer — produced as part of the stack-trace consumer's work — walks the tree and emits -file/-line directives into the text stream at the right boundaries, or records a side-table of {module, function, source_line} → {file, line, col} mappings the BEAM-side stack-trace decoder consumes.

This is materially weaker than the cerl path's per-node annotations (which would survive into BEAM bytecode via debug_info). The trade-off was accepted in the Phase 0c memo; if the stack-traces consumer concludes that the side-table approach isn't viable, the cerl-as-wire question reopens as an independent ADR.

7. Migration footprint estimate

MetricEstimateSource
Document::String(...) in codegen/core_erlang/~2,300grep -rEc "Document::String\(" crates/beamtalk-core/src/codegen/core_erlang/
Document::Eco(...) in codegen/core_erlang/~60same grep for Document::Eco(
Document::String(...) in repl/codegen.rs~19REPL Core Erlang codegen
Document::String(...) in unparse/mod.rs~65Beamtalk source unparser — migrates to parallel unparse::leaf API (different escaping rules)
Total sites in scope for document::leaf migration~2,380core_erlang String + Eco + REPL String
Total sites in unparse::leaf migration~65scheduled before Documentable<'a> for String impl is removed
Files touched~36core_erlang (~34) + repl/codegen.rs + unparse/mod.rs
Aggregate char shrinkage−8.7%Phase 0c memo, 3-function projection
Helper-library LOC~50seven functions + doc comments, audit baseline is 10 LOC for two
Wire-format changes0typed-leaves does not touch the Port
Erlang-side changes0typed-leaves does not touch beamtalk_compiler_server

The hot spots by call-site count:

FileSites
control_flow/list_ops/transform_ops.rs438
control_flow/mod.rs206
dispatch_codegen.rs179
control_flow/list_ops/search_ops.rs173
gen_server/methods.rs169
expressions.rs167
value_type_codegen.rs136
intrinsics.rs131
(long tail)rest

Prior Art

The Phase 0c memo's prior-art column was the cerl-direct path itself; this ADR's prior art is "typed wrappers around a string-buffered pretty-printer", which is a more common pattern.

SystemApproachWhat we adopt / reject
Gleam's Document (the ancestor of ours, from which ADR 0018 borrows directly)Leaves are String / EcoString; no semantic typing on leaves; identifier escaping handled per-call-site by the codegen author.Same shape we have today. We're departing from the ancestor for the same reason BT-875 keeps recurring: when the leaf accepts arbitrary text, the author owns the escape boundary.
rustc's rustc_ast_prettyWraps String buffer with Printer API; tokens are typed (hardbreak, space, ibox, cbox) but identifiers are still rendered via word(s) taking arbitrary text.Same trade-off as us; rustc gets away with it because rendered identifiers don't cross a syntactic safety boundary (rustc emits Rust source that rustc itself reparses with full diagnostics). For Core Erlang we emit text that core_parse rejects with line-number errors, which is exactly where BT-875 bugs hide.
Pharo / Squeak's Smalltalk CompilerCompiler operates on AST nodes in-image; "codegen text" doesn't exist as a stable artefact — bytecode emission is a method on the node.The Smalltalk way is "compiler in the image", which collapses the text layer entirely. We can't match this directly (Rust compiler, BEAM runtime). The principle that survives is "leaves carry intent, not arbitrary text"; typed-leaves applies it.
LFE's lfe_codegenProduces cerl records directly; no text layer, no leaf-typing question.This is the path ADR 0088 was going to take. Phase 0c data shows it isn't worth ~30× the migration cost for our case.
Elixir's Macro.to_stringQuoted-form-to-string conversion uses typed AST nodes; the inverse direction (text codegen) doesn't exist because Elixir doesn't emit text.Same as LFE — no text layer. Not a reachable design point for us without rebuilding the wire (ADR 0088).
Pretty-printer libraries in general (prettyprinter Haskell, pretty Wadler)The leaves are strings; semantic typing of leaves is application-specific.Confirms the typed-leaf API is application code, not pretty-printer library code. We add the typed helpers in our crate, not in the Document library.

Pattern. Pretty-printer libraries leave leaf-typing to the application; languages that emit text codegen either (a) accept the recurring-typo cost (rustc, Gleam, current Beamtalk), (b) layer typed helpers on top (this ADR), or (c) skip the text layer entirely (Pharo, LFE, Elixir). Path (c) is the cleanest but the most expensive; path (b) is the affordable compromise.

User Impact

PersonaImpact
NewcomerNone — language semantics, REPL output, compiled artefacts unchanged.
Smalltalk developerNone — language semantics unchanged.
Erlang/BEAM developerNone — generated Core Erlang text and .beam artefacts byte-for-byte identical.
Production operatorNone — no runtime change. Compile time unchanged (no wire change).
Compiler contributorNet positive but with adjustment cost. New codegen authors learn leaf::atom/leaf::var/leaf::string_lit instead of reaching for Document::String. The escape-boundary class of bug (BT-875) becomes impossible by construction. Compile errors for the variant ("use of Document::String is undefined") direct authors to the typed helpers. ~50 lines of new library code to learn; the existing combinator surface (docvec!, join, nest, group, break_) is unchanged.
Tooling developerMildly positive. The annotation-extension sketch (§6) leaves a path for future BT-aware stack traces without re-opening ADR 0088.

Discoverability

The document::leaf submodule and its seven helpers are discoverable via standard rustdoc / cargo doc browsing and via the use statement at the top of every migrated codegen file. New codegen contributors who reach for Document::String get a compile error ("no variant String on Document") immediately after the migration PR lands; the error message can be paired with a compile_error! helper that points at document::leaf for one release cycle after the deletion.

Steelman Analysis

Option A: Typed-Leaf Helpers + Flag-Day Removal (Recommended)

Option B: Soft Seal — Document::String pub(super), Keep the Variant

Option C: Status Quo — CLAUDE.md Rule + Manual Review

Option D: Full Cerl-Direct Migration (the rejected ADR 0088 path)

Referenced for completeness; not re-litigated. See ADR 0088 Status block for the rejection rationale and Phase 0c memo for the empirical data driving it.

Tension Points

Alternatives Considered

1. Status quo — CLAUDE.md rule + manual review (current BT-875 enforcement)

Keep Document::String as-is. Trust the CLAUDE.md rule and code review to catch new format!() / string-concatenation regressions.

Rejected because:

2. Document::String and Document::Eco as pub(super) — soft seal, no removal

Move Document::String and Document::Eco behind a module-private visibility so only the document::leaf helpers can construct them; the variants continue to exist as implementation details.

Rejected because:

3. Full sum-type replacement, hard delete (the recommended option, articulated as the alternative for the reader who wants the full reasoning)

Replace Document::String with the seven-helper document::leaf API and remove the variant in one PR.

Accepted as Decision. See Decision §3 above for the full articulation.

4. Full cerl-direct migration (the rejected ADR 0088 path)

Replace the Document tree with a typed cerl::Expr AST and ship ETF across the Port.

Rejected because:

5. Extend Document with typed leaf enum variants directly

Add Document::Atom(String), Document::Var(String), etc. variants to the enum; the renderer adds new arms for each.

Rejected because:

Consequences

Positive

Negative

Neutral

Implementation

This is a single epic with a flag-day deletion. The /plan-adr output decomposes the implementation; this section names the phases at the level the ADR commits to.

Phase A: Helper Library + Lint (S)

Phase B: Flag-Day Migration + Variant Removal (L)

A single PR that:

  1. Greps every Document::String(...) and Document::Eco(...) site across the codegen tree, classifies it (atom / var / string_lit / int_lit / float_lit / fname), and rewrites it to the corresponding helper call. The classification is mostly mechanical (surrounding atom-quotes ⇒ atom, capitalised identifier ⇒ var, .to_string() on a number ⇒ int_lit/float_lit); ambiguous sites get a per-site reviewer note in the PR.
  2. Removes Document::String and Document::Eco from the Document enum in document.rs. Removes the Documentable<'a> for String and Documentable<'a> for EcoString impls. The Documentable<'a> for usize / for isize impls migrate to call int_lit internally. Document::Str(&'static str) stays — it can only carry compile-time constants and is not a BT-875 vector.
  3. Removes the cerl_audit.rs and typed_leaves_audit.rs modules (Phase 0c memo flagged them as throwaway).
  4. Updates CLAUDE.md to reflect the new invariant: the typed-leaf API is the only way to introduce a leaf; Document::String no longer exists. The format!() ban remains.

The PR ships green: all ~245 codegen unit tests, the proptest suite (core_erlang_validity_tests.rs), and the full behavioural suite (just test-stdlib/test-bunit/test-repl-protocol) pass. Generated .beam artefacts are byte-for-byte identical to the pre-PR baseline.

Phase C: Helper Refinements (S, ongoing)

After Phase B lands, opportunistic refinements as feature work exercises the helpers:

Phase D: Annotation Extension (Deferred — sibling ADR)

When the BT-aware stack-traces consumer is ready to ship, the Document::Annotated(Annotation, Box<Document>) variant + the annotation-collecting renderer + the side-table format land as a sibling ADR. Not part of this epic.

Affected Components

Verification

References