ADR 0061: Program Entry Points and Run Lifecycle

Status

Accepted (2026-03-10)

Context

The Problem

beamtalk run has accumulated three separate entry-point mechanisms with inconsistent behaviour and a critical class-loading bug:

  1. [package] start = "module" — calls bt@pkg@module:start/0 directly after starting beamtalk_runtime. Added early; never widely used.
  2. [run] entry = "Main run" — a Beamtalk expression evaluated at REPL startup. A poor-man's entry point added before proper OTP integration existed. Recognised by beamtalk repl but silently ignored by beamtalk run (sicp fails with "No start module defined").
  3. [application] supervisor = "AppSup" — starts the package as an OTP application. Works structurally but crashes at startup because child classes (WorkerPool, EventLogger) are not registered in the class registry when the root supervisor's init/1 calls class_children. The beamtalk_workspace bootstrap that topologically sorts and registers all project classes is never started in the beamtalk run path.

The root cause of the class-loading bug: beamtalk run only starts beamtalk_runtime. The workspace — which contains beamtalk_workspace_bootstrap — is only started by beamtalk repl. Any program referencing more than one class is therefore fragile under beamtalk run.

Two Distinct Program Kinds

Real-world packages fall into two categories with different lifecycle needs:

Scripts: batch programs that run to completion and exit. Compilers, test harnesses, code generators. Operator expectation: beamtalk run ClassName selector starts the program in the foreground and the shell returns when it finishes.

Services ([application]): long-running OTP applications with supervision trees. Operator expectation: beamtalk run . starts the service in the background and the shell returns immediately. The service stays running and can be inspected or controlled later by connecting a REPL.

The existing workspace registry (~/.beamtalk/workspaces/) and --ephemeral flag already provide the right lifecycle primitives for services.

Decision

1. Entry-point model

Scripts and services are invoked differently:

# Script: explicit class and selector on the CLI
beamtalk run Main run
beamtalk run Database migrate

# Service: supervisor declared in toml, started by beamtalk run .
# beamtalk.toml:
[application]
supervisor = "AppSup"

[run] is removed. It was a workaround — a way to express "run this" before proper OTP integration existed. The right place for a script entry point is the CLI, not config. [package] start is also removed — superseded by the CLI form.

Future: [scripts] as a named-shortcut table (like npm scripts or Justfile) is planned but deferred. It will allow beamtalk run migrate as a shorthand for beamtalk run Database migrate, documented in the project manifest.

2. Operator mental model

The workspace is the runtime environment for all Beamtalk programs. It bootstraps all project classes before any user code runs, hosts singletons like TranscriptStream, and supervises actors. There are two workspace modes:

Run mode (scripts): workspace starts, program runs, workspace exits with the program. Not registered in ~/.beamtalk/workspaces/, no REPL listener — not connectable. Use beamtalk repl . if interactive access is needed.

Persistent mode (services and all beamtalk repl sessions): workspace registers in ~/.beamtalk/workspaces/, starts a REPL server on a free port, remains alive until explicitly stopped or --ephemeral session disconnects.

The rule: if beamtalk run returns your shell, the workspace persists. If it blocks, the workspace exits with the program.

3. Command behaviour

CommandScriptService ([application])
beamtalk run Main runRun mode workspace → call Main>>runblocks, exit on return
beamtalk run Main run (workspace running)Connect to existing workspace → call Main>>run → exit
beamtalk run .Persistent workspace → start OTP app → shell returns, service running
beamtalk run . (service running)Print existing port → exit 0 (idempotent)
beamtalk repl . (no workspace)Persistent workspace → open REPLPersistent workspace → start OTP app → open REPL
beamtalk repl . (workspace running)Connect to existing workspaceConnect to existing workspace
beamtalk repl . --ephemeralPersistent workspace → REPL → tears down on disconnectPersistent workspace → OTP app → REPL → tears down on disconnect

What an operator sees when running a service:

$ beamtalk run .
Building...
Started otp_tree v0.1.0
  Supervisor : AppSup
  REPL port  : 4001   (connect with: beamtalk repl)
$

Running a script against a live service:

$ beamtalk run .          # service already running
otp_tree v0.1.0 is already running (REPL port 4001)
$ beamtalk run Database migrate
Running Database>>migrate in otp_tree workspace...
Migrated 3 schemas.
$

4. Workspace-first startup (structural fix)

Both modes start the workspace before executing any user code. This guarantees all project classes are registered before any method dispatch occurs, fixing the OTP supervisor class-loading crash.

Script startup:

beamtalk run Main run
  → build project
  → start beamtalk_runtime
  → start beamtalk_workspace (run mode: no REPL server, not registered)
      → bootstrap loads + registers all bt@*.beam in topo order
      → TranscriptStream, actor_sup, singletons all running
  → resolve class 'Main', call run/0
  → BEAM node exits when run/0 returns

Script against running service:

beamtalk run Database migrate
  → detect existing workspace for project path in ~/.beamtalk/workspaces/
  → connect to REPL server on existing workspace port
  → eval Database>>migrate in live workspace
  → disconnect, exit

Service startup:

beamtalk run .
  → build project
  → start beamtalk_runtime
  → start beamtalk_workspace (persistent: REPL server, registered in ~/.beamtalk/workspaces/)
      → bootstrap loads + registers all bt@*.beam in topo order
  → application:ensure_all_started(app_name)
      → OTP supervisor tree starts; class registry fully populated
  → print "Started <app> — REPL port <N>"
  → CLI process exits; BEAM node continues running

5. Workspace run mode

beamtalk_workspace_sup gains a repl => boolean() config key (default true). When repl = false (run mode):

Omitted:

Always present:

Run-mode workspaces are not registered in ~/.beamtalk/workspaces/ — there is no REPL server to connect to.

Prior Art

Elixir / Mix

mix run executes a script and exits. mix phx.server starts a long-running service. Releases (mix release) produce bin/app start (daemon) and bin/app console (interactive). Script/service distinction is in separate commands. Beamtalk uses one command (beamtalk run) dispatching on whether a class/selector is given or . is used.

Erlang / rebar3

rebar3 shell starts an Erlang shell with the project loaded — analogous to beamtalk repl. No single equivalent of beamtalk run for ad-hoc execution; developers use erl -eval. Beamtalk improves on this with a first-class run command handling class loading automatically.

Node.js / npm

node index.js runs a script. npm run migrate executes named scripts from package.json. Long-running services use external process managers. The npm named-scripts model is the inspiration for the planned [scripts] table (future work).

Go

go run . compiles and runs in the foreground. Single entry point (func main()). No built-in daemon mode. Simpler than Beamtalk's model but less expressive for supervised services.

What Beamtalk adopts

User Impact

Newcomer

Scripts are invoked explicitly: beamtalk run Main run. No separate entry-point config is needed for one-off runs; the command still runs within a Beamtalk project context (a beamtalk.toml manifest is still required). Services are declared in toml and started with beamtalk run .. Error messages guide migration from [run] and [package] start.

Smalltalk developer

Familiar with image-based persistence and interactive environments. The persistent workspace model maps naturally — the workspace is an image-like environment that survives the REPL session. TranscriptStream works as expected in both modes.

Erlang/OTP developer

Services use standard OTP application and supervisor mechanics. The workspace is an OTP application sitting above beamtalk_runtime. The generated OTP app callback follows standard OTP patterns. Note: beamtalk_workspace_sup appears in the OTP supervision tree — operators using observer need to know it is the Beamtalk runtime layer, not user code.

Operator

beamtalk run . on a service returns the shell with a REPL port printed. beamtalk repl connects. beamtalk run ClassName selector runs scripts, connects to existing workspace if one is running. --foreground (planned) for CI/systemd.

Steelman Analysis

"Keep [run] — explicit entry point documentation"

The [run] section tells anyone reading the project what the entry point is, without running the code. beamtalk run Main run on the CLI has no discoverable default for the project. Counter: [scripts] (planned) solves this better — named, structured shortcuts documented in toml without the [run] string-expression limitation. The absence of [run] is temporary, not permanent.

"Always use full workspace — simpler mental model"

Two workspace modes (run vs persistent) mean operators must learn when each applies. Counter: short-lived script workspaces polluting ~/.beamtalk/workspaces/ and binding TCP ports is real operational noise. The two-mode model maps to a real distinction in program kind.

"Services should block by default — principle of least surprise"

beamtalk run . blocking until exit is what Node, Go, Python all do. Detach-by-default is surprising, especially in CI. Counter: local development (the primary target) wants the shell back. --foreground covers CI. The tension is real and --foreground is planned.

"Config-driven entry point is better — no magic CLI incantation"

Forgetting beamtalk run Main run is easy; the toml documents what to run. Counter: this is exactly what [scripts] solves, without the [run] string-expression footgun. The right fix is structured named scripts, not a free-form expression in config.

Alternatives Considered

Keep [run] with structured fields

[run]
class = "Main"
selector = "run"

Structured (Anders-friendly), no magic string. But still config-driven, still only one entry point per project, still requires a toml change for every new script. The CLI form (beamtalk run Main run) is strictly more flexible with no downsides for the common case.

Blocking foreground mode for services (--foreground)

Keep beamtalk run . blocking for [application]. CI environments, Docker, and systemd require foreground. Partially accepted: --foreground is planned as a follow-up flag. Default remains detach-and-persist for local development.

OTP releases

OTP releases are the canonical solution for supervised BEAM applications as daemons. Rejected for this ADR: heavier than needed for local development, do not integrate with the workspace/REPL model. Planned as a future ADR. See also: Future Release Mode section.

Named scripts now ([scripts])

[scripts]
migrate = { class = "Database", selector = "migrate" }

The right long-term answer. Deferred: the CLI form covers the immediate need and [scripts] can be added without breaking changes later.

Consequences

Positive

Negative

Neutral

Implementation

Phase 1 — Workspace run mode (beamtalk_workspace_sup.erl) — BT-1317

Phase 2 — OTP app callback starts full workspace before supervisor (build.rs, run.rs) — BT-1319

Phase 3 — Unified beamtalk run (run.rs, manifest.rs) — BT-1320

Migration Path

[package] start = "module":

# Before: beamtalk.toml had start = "main"
# After:
beamtalk run Main start

[run] entry = "Main run":

# Before: beamtalk.toml had [run] entry = "Main run"
# After:
beamtalk run Main run

This is a breaking change. The new CLI contract is beamtalk run <ClassName> <selector> — two positional arguments only. The old [run] entry field accepted arbitrary Beamtalk expressions (including keyword selectors with arguments, e.g. entry = "App start: 'production'"); the new CLI does not. Keyword-selector and multi-argument entry expressions must be refactored into a unary entry point or wrapped behind a named script (see planned [scripts] table). Update beamtalk.toml and all CI scripts to use the positional form.

beamtalk repl auto-eval of [run] entry: Remove [run] from toml. If the auto-eval was used to warm up the REPL, start the service with beamtalk run . first and connect with beamtalk repl.

Future: Release Mode

beamtalk release (planned, separate ADR) produces a self-contained OTP release for production deployment. This introduces a third operational mode:

ModeBootstrapREPL serverWorkspace registryUse case
Run mode (repl=false)Scripts via beamtalk run
Full workspaceDev: beamtalk run (services) and beamtalk repl
Release✗ (pre-loaded)Production deployments

Class loading in releases: All bt@*.beam files bundled and pre-loaded at boot. beamtalk_workspace_bootstrap not needed — classes registered before supervisor starts. Sidesteps the class-loading problem ADR 0061 fixes for dev.

REPL against a release: beamtalk repl --host prod.example.com --port 4001 connects via the same protocol as dev. The release includes a minimal REPL server (beamtalk_repl_server + beamtalk_session_sup) without the full workspace. Security defaults stricter: TLS + auth required (see ADR 0058).

Design constraint for the release ADR: beamtalk_workspace_sup must support release mode cleanly — either via a third config variant or by extracting beamtalk_repl_server + beamtalk_session_sup into a standalone OTP application releases can include without the full workspace.

Design Notes

Multiple beamtalk run . on a running service: Idempotent. Scans ~/.beamtalk/workspaces/ for a live workspace matching the project path. If found, prints "Already running (REPL port N) — connect with: beamtalk repl" and exits 0. Consistent with systemctl start / docker start conventions.

beamtalk test class loading: Not affected. The test runner explicitly calls code:ensure_loaded/1 on each package module before running EUnit, triggering -on_loadregister_class/0. Classes registered before any dispatch.

BT-1318 cancelled: The generated entry module for [run] is no longer needed — [run] is removed entirely. BT-1320 absorbs the CLI arg parsing work.

References