ADR 0026: Package Definition and Project Manifest

Status

Implemented (2026-02-17)

Context

The Problem

Beamtalk has a beamtalk new command that scaffolds a project with a beamtalk.toml manifest, but:

  1. The manifest is never parsedbeamtalk build ignores beamtalk.toml entirely. It simply finds all .bt files and compiles them. The manifest is decorative.

  2. "Package" is undefined — There is no formal definition of what constitutes a Beamtalk package. The architecture doc shows a my_project/ layout with beamtalk.toml, src/, test/, _build/, and deps/, but this is aspirational — the build system doesn't enforce or use any of it.

  3. No OTP application mapping — A Beamtalk package should compile to an OTP application (.app file) so it can participate in the BEAM ecosystem: be started, stopped, supervised, and depended on by Erlang/Elixir code. Today, beamtalk build produces loose .beam files with no application metadata.

  4. Module naming is disconnectedADR 0016 established bt@package@module naming (e.g., bt@stdlib@integer), but the build system has no concept of "which package am I building?" to fill in the middle segment.

  5. No entry point convention — There's no standard way to say "run this package" (beamtalk run). The current beamtalk run command exists but has no manifest-driven entry point.

Current State

beamtalk new myapp creates:

myapp/
├── beamtalk.toml       # Decorative — never read
├── src/
│   └── main.bt         # Entry point by convention
├── README.md
└── .gitignore

beamtalk.toml template:

[package]
name = "myapp"
version = "0.1.0"

[dependencies]

beamtalk build does:

  1. Find all .bt files recursively
  2. Compile each to Core Erlang (.core)
  3. Compile each .core to BEAM (.beam) via erlc
  4. Place all outputs in build/

What's missing:

Constraints

Decision

1. A Package is the unit of code distribution and compilation

A package is a directory containing a beamtalk.toml manifest. It is:

There is no separate "project" concept. Following Gleam and Cargo, the term package covers both local development and distribution.

Relationship to workspaces (ADR 0004): Your package is the workspace — it's the running application you develop interactively. Dependency packages are libraries loaded into your workspace. When you beamtalk repl in a package directory, you create (or reconnect to) a workspace that owns that package's actors and state. Dependencies provide classes but don't own the workspace. The lifecycle is: author (source files) → develop (workspace = your running package) → deploy (OTP release).

Long-term vision: hybrid workspace-as-package. This ADR establishes the file-based foundation (Option A), but the architecture is designed to evolve toward a hybrid model where the workspace is the primary artifact and source files are a synced view (see Future Direction). Each phase of this ADR — manifest parsing, workspace auto-compile, application start — is a stepping stone toward that goal. Option A is not the destination; it's the pragmatic starting point that lets us ship today while building toward the bold choice incrementally.

2. beamtalk.toml manifest format

[package]
name = "my_counter"
version = "0.1.0"
description = "A simple counter example"
licenses = ["Apache-2.0"]

# Optional metadata (for Hex.pm publishing — future ADR)
# repository = "https://github.com/user/my_counter"
# links = { homepage = "https://example.com" }

[dependencies]
# Future ADR — dependency resolution
# beamtalk_json = "~> 1.0"

Required fields:

Optional fields (initial):

Reserved for future ADRs:

3. Package ↔ OTP Application mapping

Each package compiles to a single OTP application. The build system generates a .app file:

{application, my_counter, [
    {description, "A simple counter example"},
    {vsn, "0.1.0"},
    {modules, ['bt@my_counter@main', 'bt@my_counter@counter']},
    {registered, []},
    {applications, [kernel, stdlib, beamtalk_runtime]},
    {env, [
        {classes, [
            {'bt@my_counter@counter', 'Counter', 'Actor'}
        ]}
    ]}
]}.

Key mappings:

beamtalk.tomlOTP .app
nameApplication name
version{vsn, ...}
description{description, ...}
Source files{modules, [...]} — auto-discovered
Class definitions{env, [{classes, [...]}]} — for runtime registration

Every package implicitly depends on beamtalk_runtime (and transitively beamtalk_stdlib).

4. Module naming integration

When building a package, source files are named according to ADR 0016:

src/counter.bt    → bt@my_counter@counter
src/main.bt       → bt@my_counter@main
src/util/math.bt  → bt@my_counter@util@math

The formula is: bt@{package_name}@{relative_path_without_extension} where path separators become @.

Subdirectories within src/ are namespacing only — they do not create subpackages. The entire src/ tree belongs to one flat package. This follows the Gleam model.

Class name uniqueness (temporary constraint): Within a workspace, class names must be globally unique across all loaded packages. If your package and a dependency both define Counter, the compiler will report an error. The BEAM module names are already unique (bt@my_app@counter vs bt@other_lib@counter), but Beamtalk dispatches by class name, not module name. This is a known limitation — qualified class names (e.g., Counting.Counter) require a module/namespace system (future ADR, high priority once dependencies are supported). Until then, the strict compiler error keeps behavior predictable.

Single-file mode (no manifest): When beamtalk build file.bt is invoked on a file outside any package, the module name is the file stem without any package prefix (current behavior, preserved for scripting/experimentation).

5. Directory structure

my_counter/
├── beamtalk.toml           # Package manifest (required)
├── src/                    # Source files (required)
│   ├── main.bt             # Entry point (conventional)
│   └── counter.bt          # Additional modules
├── test/                   # BUnit tests (optional)
│   └── counter_test.bt
├── _build/                 # Build output (generated)
│   └── dev/                # Profile (future: test, prod)
│       └── ebin/           # .beam files + .app file
│           ├── my_counter.app
│           ├── bt@my_counter@main.beam
│           └── bt@my_counter@counter.beam
├── AGENTS.md               # AI agent guide (generated)
├── .github/
│   └── copilot-instructions.md  # Copilot custom instructions (generated)
├── .mcp.json               # MCP server config (generated)
├── README.md               # Documentation (optional)
└── .gitignore

Changes from current layout:

CurrentNewReason
build/_build/dev/ebin/Profile support, matches Erlang/Elixir convention
No .app file_build/dev/ebin/{name}.appOTP application
Flat .beam outputNamespaced .beam filesADR 0016 module naming

6. Build behavior changes

Package mode (manifest found):

$ cd my_counter
$ beamtalk build
Building my_counter v0.1.0
  Compiling counter.bt → bt@my_counter@counter
  Compiling main.bt → bt@my_counter@main
  Generating my_counter.app
Build complete: 2 modules in _build/dev/ebin/

The build system:

  1. Reads beamtalk.toml for package name and version
  2. Discovers .bt files in src/ (not the root — prevents compiling test files)
  3. Compiles each with bt@{name}@ prefix via the embedded compiler port (ADR 0022)
  4. Generates .app file with module list and class metadata
  5. Writes .beam files to _build/dev/ebin/ relative to the package root (where beamtalk.toml lives), regardless of the current working directory

Single compilation path: Both beamtalk build and beamtalk repl use the same embedded compiler port (ADR 0022). There are no intermediate .core files on disk — compilation is fully in-memory (Source → Port → Core Erlang → compile:forms → .beam). For debugging, beamtalk build --emit-core can dump Core Erlang to disk.

Note: This fixes a current bug where beamtalk build creates build/ relative to the CWD. In package mode, _build/ is always anchored to the manifest location.

File mode (no manifest):

$ beamtalk build script.bt
  Compiling script.bt → script
Build complete: 1 module in build/

Preserved for quick scripting — no package prefix, output to build/.

7. beamtalk new scaffolding updates

$ beamtalk new my_counter
Created package 'my_counter'

Next steps:
  cd my_counter
  beamtalk build
  beamtalk repl

The scaffold creates the directory structure from §5 with:

8. Package name validation

Package names must be:

✅ my_counter, json_parser, web_utils
❌ MyCounter, 123app, beamtalk, -dashes-, CamelCase, stdlib, kernel

Error on invalid name:

$ beamtalk new stdlib
Error: 'stdlib' is a reserved package name (conflicts with Beamtalk standard library)

$ beamtalk new MyApp
Error: Package name 'MyApp' is invalid — must be lowercase (try 'my_app')

Prior Art

Gleam (gleam.toml)

Closest model. Simple TOML manifest, compiles to OTP applications, publishes to Hex.pm. Uses @ separator for module namespacing (gleam@json). Package = project, no distinction. gleam new scaffolds everything. PubGrub for dependency resolution.

Adopted: TOML format, package-as-project terminology, @ naming (already in ADR 0016), .app generation, _build/ directory.

Cargo (Cargo.toml)

Rich manifest supporting workspaces, multiple targets (stdlib/src/bin/test/bench), feature flags. Distinguishes "package" (manifest unit) from "crate" (compilation unit). Extremely mature dependency resolution.

Adopted: Manifest-driven builds, semantic versioning, required name+version. Not adopted: Package/crate distinction (too complex for Beamtalk's needs), [[bin]] target syntax.

Elixir (mix.exs)

Code-as-configuration (Elixir script, not data format). Tight OTP integration — every Mix project is an OTP application. mix new scaffolds with supervision tree option.

Adopted: Every package = OTP application, implicit beamtalk_runtime dependency. Not adopted: Code-as-config format (TOML is simpler, more tooling-friendly).

Erlang (rebar.config)

Erlang-term configuration. Close to OTP primitives. Umbrella projects via apps/ directory. Release management via relx.

Adopted: .app file generation, ebin/ output directory. Not adopted: Erlang-term config format (TOML is more approachable).

Pharo (Monticello + Metacello)

Image-based — no files on disk in the traditional sense. Packages are in-image collections of classes/methods. Metacello adds project-level dependency management on top.

Not adopted: Image-based model doesn't translate to file-based compilation. But the philosophy — a package is a coherent collection of classes — informs our design.

Newspeak

No packages or global namespace at all. Modules are top-level classes. Dependencies are constructor parameters (explicit injection). Extremely pure, but requires an image-based environment.

Not adopted: Pure DI module system is too radical for file-based BEAM compilation. But the principle — explicit dependencies — influences our future dependency design.

User Impact

Newcomer (from Python/JS/Ruby)

beamtalk new + beamtalk build works like cargo new + cargo build or gleam new + gleam build. The beamtalk.toml format is familiar TOML. Source goes in src/, tests in test/, output in _build/. No surprises.

Smalltalk developer

Departure from image-based development — packages are directories, not in-image collections. But the REPL/workspace (ADR 0004) provides the interactive, live-coding experience. The package is the on-disk representation of what would be a Monticello package.

Erlang/Elixir developer

Packages compile to OTP applications — this is exactly what they expect. The .app file, ebin/ directory, and application dependency tree are all standard BEAM patterns. They can depend on a Beamtalk package from rebar3 or Mix.

Production operator

OTP application structure means standard release tooling works: relx, mix release, rebar3 release. Applications start/stop cleanly with supervision trees. Observable with observer, recon, etc.

Steelman Analysis

Option A: Manifest-driven packages (this decision)

Option B: Convention-only (no manifest, directory structure implies package)

Option C: Smalltalk-style image packages (Monticello-like)

Option D: Beamtalk-as-config (manifest written in Beamtalk, like mix.exs)

Tension Points

  1. The Workspace ParadoxADR 0004 already creates persistent workspaces with running actors that survive disconnection. This is an image model, just not called that. If workspaces are essentially long-running BEAM nodes with state, why not embrace that and make the workspace exportable as the package format (Option C)? Resolution: We will — but incrementally. Option A builds the foundation (manifest, OTP mapping, build output) that Option C needs anyway. The hybrid destination is workspace-primary with source files as a synced view, giving image benefits with file-based tooling compatibility. See Future Direction.

  2. Interop vs Purity — The strongest argument for A is BEAM interop: OTP applications, Hex.pm, rebar3/Mix compatibility. The strongest argument for C is internal consistency: the running system is the truth. The hybrid approach resolves this: the workspace is the truth at development time, but it exports standard OTP releases and generates .app files for ecosystem interop. Both sides get what they need.

  3. The Go Counter-Example — Option B has a real case study: Go thrived for years with convention-only (GOPATH). Modules (go.mod) were controversial when introduced. But Go's convention-only also created problems: vendoring complexity, GOPATH friction, version pinning hacks. The question is whether Beamtalk should learn from Go's eventual destination (manifests) or its successful journey (start convention-only, add manifests when the pain is clear).

  4. Metadata Drift — Option A introduces a class of bugs that B and C don't have: the manifest says one thing, the code says another. Version numbers not bumped, descriptions stale, module lists out of sync. Auto-generation mitigates this (we generate .app from source scanning) but doesn't eliminate it (version must be manually maintained).

  5. Convention vs Explicit — Option B argues conventions can replace manifests. True for some metadata (directory name → package name) but problematic for others (version numbers, licenses). The tension is "derive what you can" (less boilerplate) vs "declare what you mean" (less magic). Reasonable engineers disagree here.

  6. Build Reproducibility — Manifests enable lockfiles and deterministic builds; images snapshot state but don't guarantee that independent rebuilds produce identical results. For CI/CD pipelines, manifests win; for deployment snapshots, images win.

  7. Team Development — Manifests and source files merge in Git; images require bespoke diff/merge/introspection tooling that doesn't exist yet. For teams using standard code review workflows, this is a hard constraint favoring A.

  8. Debugging Production — Source files are greppable, log-linkable, and map to stack traces. Images demand specialized introspection tools (observer, process inspection). BEAM veterans have these tools; newcomers don't.

Alternatives Considered

Alternative: No manifest — derive everything from directory name

Package name = directory name, version = git tag, no beamtalk.toml needed.

Rejected because: Can't set description, license, or any metadata. Git tags are unreliable as the sole version source. Breaks when directory is renamed. Every other BEAM language uses a manifest.

Alternative: Erlang-term config (beamtalk.config)

Use Erlang term format instead of TOML for the manifest.

Rejected because: Erlang terms are unfamiliar to newcomers. TOML is the established choice for new languages (Gleam, Cargo, Hugo, etc.). TOML has better editor support and is more readable.

Alternative: Multiple packages per repository (workspace support)

Support a [workspace] section in beamtalk.toml listing sub-packages, like Cargo workspaces.

Deferred: Valuable for large projects but adds complexity. Can be added as an extension to beamtalk.toml in a future ADR without breaking changes. Start with one package per repository.

Alternative: Subpackages (Pharo-style)

Allow subdirectories within src/ to be independent packages with their own beamtalk.toml, similar to Pharo's package categories or Cargo workspaces.

Rejected in favor of flat packages with nested modules: Subdirectories within src/ are purely namespace segments — src/util/math.bt becomes module bt@my_app@util@math within the single my_app package. This follows the Gleam model and is the simplest design. Multi-package repositories can be supported later via workspaces without breaking this convention.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1: Manifest parsing

Phase 2: Build output restructuring

Phase 3: Scaffold updates

Phase 4: Workspace integration

Phase 5: Application start callback

A package without a start callback is a library — it provides classes but doesn't do anything on its own. A package with a start callback is an application — it runs.

The manifest declares the entry point:

[package]
name = "my_web_app"
version = "0.1.0"
start = "app"    # Module containing start method (src/app.bt)

The start module contains a start method — conceptually like main in C/Go, but it starts a long-running system rather than running to completion:

// src/app.bt — Application entry point
start =>
    server := WebServer spawn
    server listen: 8080
    Transcript show: 'Web server started on port 8080'; cr

How it works under the hood:

  1. beamtalk run compiles the package (Phase 2)
  2. Starts a BEAM node with beamtalk_runtime and beamtalk_stdlib
  3. Loads the package's .app file (adds _build/dev/ebin/ to code path)
  4. Calls the start module's start method
  5. The BEAM node keeps running (actors are supervised under beamtalk_actor_sup)

The start method is imperative — it spawns actors and they're auto-supervised. This works today because all Beamtalk actors are already gen_server processes under beamtalk_actor_sup. A future ADR on OTP Behaviour Mapping may evolve this to support declarative supervision trees and restart strategies, but the simple imperative start is the right starting point.

Library vs Application:

Library (no start)Application (start = "app")
beamtalk build✅ Compiles to .beam + .app✅ Same
beamtalk repl✅ Classes available✅ Classes available + start method callable
beamtalk run❌ Error: "no start module"✅ Starts the application
OTP .appNo {mod, ...} entryIncludes OTP application metadata; {mod, ...} callback and Erlang shim mechanism defined in a future ADR
As dependencyLoaded on code pathStarted as OTP application

Affected components: beamtalk-cli (build, new, run commands), beamtalk-core (module naming in codegen), workspace discovery, REPL module loading.

Estimated size: L (manifest parsing + build restructure + scaffold + integration)

Migration Path

Existing projects created with beamtalk new

Projects scaffolded before this ADR already have:

Migration steps:

  1. Delete old build/ directory
  2. Update .gitignore to include _build/ instead of /build/
  3. Run beamtalk build — output now goes to _build/dev/ebin/

Single-file scripts

No migration needed. beamtalk build file.bt continues to work as before — no package prefix, output to build/.

Stdlib and test suites

The stdlib (stdlib/src/*.bt) and test suites (stdlib/bootstrap-test/, tests/e2e/) are not packages — they are compiled by dedicated build commands (just build-stdlib, just test-stdlib, just test-e2e) that use their own module naming conventions. Package-mode compilation only activates when a beamtalk.toml is found, so these existing workflows are unaffected.

Future Direction: Toward Workspace-as-Package (Option C)

This ADR chooses Option A (manifest + source files) as the pragmatic foundation, but the long-term destination is Option C — workspaces as the primary artifact, with source files as a derived view. Option A is a stepping stone, not the end state.

The path from A to C has natural milestones, each independently valuable:

Step 1: Package foundation (this ADR)

beamtalk.toml + src/ + OTP application. Standard file-based workflow. Git, CI, code review all work out of the box.

Step 2: Workspace auto-compile (this ADR, Phase 4)

beamtalk repl in a package directory compiles source and boots the workspace. The workspace is the running package. This is already halfway to an image — the workspace has running actors, loaded classes, and live state.

Step 3: Durable workspaces

Khepri-backed persistence for workspace state — actor state, loaded classes, REPL bindings survive restarts. The workspace becomes a real live image, not just a development session. Separate ADR required.

Step 4: Workspace → source sync

Bidirectional sync between workspace state and .bt source files (inspired by Pharo's Tonel format). Define a class in the REPL → .bt file appears. Edit a .bt file → workspace hot-reloads. Source files become a view of the workspace, not the sole source of truth. This is the key enabler — it preserves Git/review/CI workflows while making the workspace primary.

Step 5: Workspace export as release

beamtalk release exports the running workspace as a deployable OTP release. No separate build step — the workspace is the built artifact. Dependencies are other workspace exports (or standard OTP applications). Hex.pm publishing generates metadata from workspace introspection.

Why not start at C?

Each step requires the previous one, and each delivers value independently:

Starting at C would require building all five simultaneously — workspace sync, Khepri integration, release export, and the manifest parsing from Option A anyway (for interop). Option A lets us ship a useful package system now while building toward the bold vision incrementally.

The BEAM makes this path uniquely viable. OTP's native hot code loading, two-version module support, release handlers, and code_change callbacks mean "workspace as deployable image" isn't a novel runtime — it's leveraging infrastructure that already exists. Beamtalk just needs to expose it with the right abstractions.

Amendment: OTP Application Root Supervisor (BT-1191)

Status: Implemented (2026-03-09)

Context

ADR 0059 (Supervision Tree Syntax) introduced Supervisor subclass: as the Beamtalk way to declare OTP supervision trees. However, the [run] entry = "Main run" pattern remained an imperative startup script — actors spawned this way are not rooted in an OTP application supervisor. If the node restarts, they do not come back automatically, and observer:start() shows them as unattached named processes.

Decision

Add an optional [application] section to beamtalk.toml:

[package]
name = "my_web_app"
version = "0.1.0"

[application]
supervisor = "AppSup"    # Root Supervisor subclass for this OTP application

When [application] supervisor is set:

  1. beamtalk build generates beamtalk_{appname}_app.erl — an OTP application callback module:

    %% Generated: beamtalk_myweb_app_app.erl
    start(_Type, _Args) -> 'bt@my_web_app@app_sup':'start_link'().
    stop(_State) -> ok.
    

    And emits {mod, {beamtalk_myweb_app_app, []}} in the .app file.

  2. beamtalk run starts the OTP application via application:ensure_all_started/1 rather than calling an imperative start method. The application root supervisor is automatically rooted in the OTP supervision tree.

  3. Workspace supervisor returns the running application root supervisor, backed by a supervisor ETS registry in beamtalk_supervisor.erl. Returns nil if no [application] section is configured or the application has not started.

The [run] entry = field continues to work for scripts and tools (short-running imperative entry points). The two modes are independent.

Manifest Schema Addition

FieldTypeRequiredDescription
[application].supervisorStringNoBeamtalk class name of the root Supervisor subclass:

Files Modified

References


Implementation Tracking

Epic: BT-600 Issues: BT-601, BT-602, BT-603, BT-604, BT-605, BT-606, BT-607 Status: ✅ Done

PhaseIssueTitleSizeBlocked by
1BT-601Parse beamtalk.toml manifestM
1BT-602Validate package namesSBT-601
2BT-603Apply bt@{package}@{module} namingMBT-601
2BT-604Build output + .app file generationMBT-601, BT-603
3BT-605Update beamtalk new scaffoldSBT-602
3BT-606REPL auto-compile on startupMBT-604
4BT-607beamtalk run with start callbackMBT-604, BT-606

References