ADR 0093: Announcements — Typed Event Substrate (Runtime Bus + stdlib Veneer)

Status

Implemented (2026-06-08)

Context

The problem

Beamtalk has no first-class way for one part of the system to say "X happened" and another part to react. Application code wants a typed Observer (when: SomethingChanged do: [...]). And the system itself increasingly wants to publish: a LiveView IDE pane needs Transcript lines, actor spawn/stop, class loads, binding changes, and supervision-tree deltas pushed to it as they happen.

This ADR decides what the pub/sub mechanism is, and — critically — where it lives (runtime / stdlib / package), so the rest of the system has one substrate to build on instead of many.

Why now: five ADRs are each about to reinvent this

Investigating ADR 0092 (supervision-tree introspection) surfaced that five separate accepted/proposed ADRs each independently plan their own bespoke "subscribe / broadcast" channel — and none is built yet:

ADRBespoke notification it plans
0017 (browser connectivity)beamtalk_transcript_stream:subscribe/unsubscribe; "actor-spawned/stopped … requires new registry notification infrastructure"
0046 (VSCode sidebar)new class-loaded push event + actors spawned/stopped channel
0054 (communication protocols)WebSocket push for Transcript + actor lifecycle; future {future_resolved} notifications
0091 (remote workspace front)push-subscription facade for transcript / actors / classes / bindings
0092 (supervision introspection)supervision-tree deltas (deferred to "the Announcements package")

That is five teams about to build five slightly-different subscribe/broadcast mechanisms. Deciding the substrate now prevents the fragmentation rather than forcing a later consolidation.

What already exists (and what it is not)

The runtime already has two event-ish mechanisms — neither is a typed Observer:

  1. pg process groups — started at boot (pg:start_link/0 in beamtalk_bootstrap) and used today as a membership registry: beamtalk_object_class joins the beamtalk_classes group so beamtalk_class_registry can enumerate live class processes. pg is untyped, stateless broadcast — no event types, no per-subscriber liveness beyond group membership, no subclass matching.
  2. telemetry — adopted by ADR 0069 as the measurement bus (telemetry:span/3 around actor dispatch, lock-free counters, ETS trace store). It is deliberately fire-and-forget, untyped (string-list event names), with handlers run synchronously in the caller process. It is the right tool for spans/metrics and the wrong tool for "subscribe to a typed domain event and react in app logic."

So Beamtalk has a membership primitive and a measurement bus — but no typed domain-event layer. That gap is what every one of the five ADRs is working around.

Prior history of this decision

The package framing assumed Announcements is a leaf capability that apps opt into. The five-consumer finding challenges that: if the system publishes through it, it cannot live in an optional package, because stdlib and the runtime cannot depend on a package (CLAUDE.md / architecture: "dependencies flow down only"). This is exactly why Pharo keeps Announcements (and SystemAnnouncer) in the core image, not a loadable package.

Constraints

  1. Dependency direction is the hard constraint. stdlib/runtime → may be depended on; a package → depends on them, never the reverse. Any system publisher forces the typed core below the package line.
  2. Do not add a third ad-hoc mechanism. We already have pg (membership) and telemetry (measurement). A typed Observer must either reuse one or justify why neither fits — and must not blur the line with telemetry's measurement role (ADR 0069).
  3. BEAM-native liveness. Subscribers are processes that can die. Cleanup must be erlang:monitor/2-driven (the BEAM analogue of Pharo's weak subscriptions), not manual.
  4. Fault isolation. A crashing subscriber handler must not take down the publisher or other subscribers (let-it-crash, Principle 10).
  5. Hot-code-reload safe. Subscriptions are process-rooted, not module-rooted, so they survive method/class reload.
  6. Structured errors & logging (CLAUDE.md): #beamtalk_error{}, ?LOG_* with domain => [beamtalk, announcements].

Decision

Adopt a three-layer design: a runtime event bus, a stdlib typed-Observer veneer (the part that was going to be a package), and a thin package for the genuinely-optional extras. Draw a firm line between this typed-domain-event substrate and telemetry's measurement role.

┌──────────────────────────────────────────────────────────────┐
│ package: beamtalk-announcements (OPTIONAL)                     │
│   distributed cross-node delivery · RecordingAnnouncer ·       │
│   telemetry/OTel bridge                                        │
├──────────────────────────────────────────────────────────────┤
│ stdlib (CORE IMAGE)                                            │
│   Announcement (base event) · Announcer (per-instance) ·       │
│   SystemAnnouncer (singleton) · when:do: / announce: / …       │
│   AnnouncementNavigation + SubscriptionNode (introspection)    │
├──────────────────────────────────────────────────────────────┤
│ runtime (Erlang)  beamtalk_announcements                       │
│   ETS subscription table (gen_server owns writes + monitors);  │
│   dispatch runs caller-side off concurrent ETS reads.          │
│   MRO match (deduped) · async + isolated sync · pg = system    │
│   subscriber-group registry (membership, not transport)        │
├──────────────────────────────────────────────────────────────┤
│ existing: pg (membership)   ·   telemetry (measurement, 0069)  │
└──────────────────────────────────────────────────────────────┘

1. Layer 1 — runtime bus (beamtalk_announcements)

A runtime module providing typed publish/subscribe, slotted into beamtalk_runtime_sup as a worker after beamtalk_bootstrap (which starts pg) — template: beamtalk_xref (ADR 0087). The design follows the same hot-path discipline as beamtalk_xref and beamtalk_trace_store: the gen_server is not in the dispatch path.

The original design reversed BT-2193's "no pg / ETS-per-announcer" choice for the shared SystemAnnouncer; as built (Amendment BT-2530) the reversal never materialised — the system bus and per-instance announcers alike use the lean ETS model BT-2193 specced, and cross-node subscribers register explicit pids.

2. Layer 2 — stdlib typed veneer

Three classes in the core image (so the system can publish):

// Base event — an immutable typed payload. Apps subclass it and add `field:`
// slots; an announcement is a *fact*, so it is a Value (never mutable). Value
// inheritance with an abstract base mirrors `abstract Value subclass: Number`.
// NOTE: as a Value, two announcements with equal fields compare `==`
// (structural). That is fine here — the dispatch path keys on event *class* +
// subscription, never on event equality, so distinct-occurrence identity is
// never needed. (Pharo's Announcement uses identity; we accept structural
// equality as the price of Value's `field:`/keyword-ctor ergonomics.)
abstract Value subclass: Announcement

// A dispatcher handle — an opaque reflection of runtime state, exactly like
// `Pid` / `Port` / `Reference` (ADR 0067, "Object's Three Roles"): an `Object
// subclass:` with no declared data. The underlying announcer is an Erlang
// `reference()` carried as the object's native boxing and reached via FFI;
// equality is by identity (`=:=`), like `Pid`.
//   - NOT a Value: we don't want `with*:` setters, structural equality,
//     `new:`-map construction, or `fieldAt:` reflection on a capability handle.
//   - NOT an Actor: there is deliberately no per-announcer process (that would
//     reintroduce the central mailbox rejected in §4). The subscriber set lives
//     in the shared runtime ETS table keyed by the ref; dispatch runs
//     caller-side. `class new` mints a fresh announcer via FFI.
typed Object subclass: Announcer
  class new -> Announcer => (Erlang beamtalk_announcements) newAnnouncer
  when: aClass :: Class do: aBlock :: Block -> Subscription => ...
  when: aClass :: Class send: sel :: Symbol to: receiver -> Subscription => ...
  when: aClass :: Class doOnce: aBlock :: Block -> Subscription => ...
  announce: anEvent :: Announcement -> Nil => ...           // async (cast)
  announceAndWait: anEvent :: Announcement -> Nil => ...     // sync (call), 5s default timeout
  announceAndWait: anEvent :: Announcement timeout: ms :: Integer -> Nil => ...
  unsubscribe: receiver -> Nil => ...
  // self-inspection (object-knows-itself; reads its own ETS rows by ref) — §7
  subscriptions -> List(SubscriptionNode) => ...
  subscribersOf: aClass :: Class -> List(SubscriptionNode) => ...
  subscriptionCount -> Integer => ...

// The one the *system* publishes through — Pharo's SystemAnnouncer.
sealed Announcer subclass: SystemAnnouncer
  class current -> SystemAnnouncer => (Erlang beamtalk_announcements) system

// The unsubscribe token returned by when:… — another opaque runtime handle
// (Object, like Announcer), wrapping the subscription's unique SubRef. Each
// when:… returns a *distinct* Subscription (a process may hold several to the
// same class — Pharo's rule), so re-subscribing never silently replaces.
sealed typed Object subclass: Subscription
  unsubscribe -> Nil => ...        // removes exactly this SubRef's row
  isActive    -> Boolean => ...    // ets:member on the SubRef; false after unsubscribe

SystemAnnouncer current is the shared bus the runtime emits onto and tools subscribe to. System facilities publish well-known discrete Announcement subclasses (stdlib-provided): ActorSpawned / ActorStopped, ClassLoaded / ClassRemoved, BindingChanged, and (ADR 0092) SupervisionChildAdded / SupervisionChildCrashed. A tool subscribes once and filters by event class instead of wiring four bespoke transports.

System events are announced only after the triggering action's metadata writes commit — e.g. ClassLoaded fires from beamtalk_object_class's handle_call reply path after the class's beamtalk_class_metadata row is written, so the MRO walk for the event (which reads that metadata) sees a consistent hierarchy. The trade is a small observable gap (class live before its announcement) over an inconsistent-read risk; the gap is the safe choice.

3. Layer 3 — the package (re-scoped BT-2193)

beamtalk-announcements shrinks to the genuinely-optional:

4. The line vs telemetry (ADR 0069)

telemetry (ADR 0069)Announcements (this ADR)
PurposeMeasurement — spans, counters, durationsTyped domain events you react to
Event identitystring list [beamtalk, actor, dispatch]Announcement subclass (typed, MRO)
Deliverysync, in caller process, fire-and-forgetasync or sync; isolated; monitored
Subscribermetrics handler (export to StatsD/OTel)app logic / IDE pane reacting
Livenessnone (handlers are module funs)monitor-based per subscriber

Rule of thumb: measure with telemetry, react with Announcements. Actor spawn/stop can legitimately produce both (a telemetry counter for ops, a SystemAnnouncer event for the IDE) — the package's bridge is how one becomes the other, without coupling the two buses.

5. What does not belong on this bus (scope boundary)

Two of the "five consumers" are not discrete domain events and must stay off SystemAnnouncer:

Per-consumer placement:

Consumer (ADR)Needs typed MRO events?Mechanism
actor spawn/stop (0017/0046)discrete, typedSystemAnnouncer (ActorSpawned/Stopped) — or a telemetry attach if the consumer only needs a counter (§4)
class load/remove (0046)discrete, typedSystemAnnouncer (ClassLoaded/Removed)
bindings changed (0091)discrete, typedSystemAnnouncer (BindingChanged)
supervision deltas (0092)discrete, typedSystemAnnouncer (SupervisionChild…)
Transcript lines (0017/0054)no — a streamdedicated beamtalk_transcript_stream, NOT this bus

So this substrate consolidates the discrete push channels; it deliberately does not swallow the Transcript stream. That is the honest line, and it keeps the bus from becoming a throughput chokepoint.

6. Security — subscription is a read capability (ties to ADR 0091)

Subscribing to SystemAnnouncer observes everything the system emits, so it is a read operation under ADR 0091's role model. The remote front's push-subscription facade (ADR 0091) is the gate: it wraps SystemAnnouncer subscription as a named, RBAC-checked op (Observer role may subscribe to the curated system events; subscribing to all events, or to internal/infra announcements, is gated like ADR 0092's system scope). Direct SystemAnnouncer current access from in-image Beamtalk code is unrestricted — but that already implies an eval-capable (Owner) session, which ADR 0091 treats as fully privileged, so no boundary is bypassed. The ADR-0091 implementation issue owns adding subscribe/unsubscribe to the authoritative op list; this ADR fixes the requirement.

Publish authority (who may announce, not just subscribe). Unlike C#'s event — where only the declaring type can raise — Beamtalk's "an announcer is just an object" model does not language-enforce who may publish (nor does Pharo's SystemAnnouncer). Two boundaries matter:

7. Introspection & navigation — the third navigation sibling

A pub/sub bus that can't be inspected is a black box, which would contradict the very reason this lives in the core (observability/tooling, §"Why now"). Beamtalk already exposes runtime wiring along two axes that share one shape — a navigator (Object) returning snapshot records (Value): SystemNavigation walks static structure (classes, sendersOf:); ADR 0092's ProcessNavigation walks the live supervision tree (SupervisionNode). Subscriptions are wiring of the same kind, so the substrate ships the third sibling in v1 — the pattern is settled, so there is no reason to defer it. (Risk, accepted: ProcessNavigation is Planned, not yet Implemented (ADR 0092 BT-2427…2433); if its shape shifts during build, AnnouncementNavigation — deliberately the same shape — co-evolves with it. The Announcer self-inspection half costs nothing extra and is independent of that risk.)

Two levels, mirroring ADR 0092's object-knows-itself / navigator-discovers- system split:

class default                          -> AnnouncementNavigation   // the system bus
class of: anAnnouncer :: Announcer     -> AnnouncementNavigation   // one announcer
  subscriptions                        -> List(SubscriptionNode)   // snapshot
  subscribersOf: aClass :: Class       -> List(SubscriptionNode)
  announcedClasses                     -> List(Class)              // distinct event types in use

It returns a read-only snapshot of the subscription graph as immutable Value records:

sealed typed Value subclass: SubscriptionNode
  field: announcementClass :: Class      = nil   // the subscribed-to event type
  field: announcer         :: Announcer  = nil   // which bus
  field: subscriber        :: Pid        = nil   // the live handler process
  field: handlerKind       :: Symbol     = nil   // #do | #send | #doOnce
  field: once              :: Boolean    = false

The read-vs-mutate rule is ADR 0092 §3a's, applied identically: a SubscriptionNode is a frozen fact and read-only; to act you cross back to the live Subscription token (unsubscribe) or the Announcer. This keeps the Object/Value split consistent across all three navigators — navigators (SystemNavigation, ProcessNavigation, AnnouncementNavigation) and live handles (Announcer, Subscription) are Object; snapshot records (SupervisionNode, SubscriptionNode) and domain events (Announcement) are Value. The IDE's "event wiring" pane (ADR 0017/0091) is AnnouncementNavigation default subscribersOf: … grouped by announcementClass. The navigator reads are gated as a system-scope read under ADR 0091, like the supervision navigator (§6).

Future work (not in scope): declarative event: emission manifest. The navigator above makes subscriptions (who listens) discoverable — a runtime fact. The symmetric half — emissions (which Announcement subclasses a class publishes) — is today invisible, buried in announce: calls in method bodies. A declarative event: member (alongside state: / field: / classState:, ADR 0067) would make emissions part of a class's published contract, enable announce: type-checking, and complete the event graph (publishers and subscribers). It is deliberately deferred to its own ADR: it is a new declarative member kind touching parser / semantic analysis / reflection / codegen, which would break this ADR's "no parser/codegen changes" property, and it is a different shape from C#'s event (a manifest of emitted types, not a per-object channel — the channel here is the Announcer). A pure-stdlib interim (class emittedEvents -> List(Class), hand-declared, no new syntax) can deliver the discoverability win first if wanted.

Update (BT-2475): the discoverability win shipped as a derived static analysis instead. Rather than a declarative manifest, the emission-side dual of AnnouncementNavigation is now SystemNavigation default announcementsSentBy: aClass — it mines the announce: / announceAndWait: / announceAndWait:timeout: call sites out of a class's method bodies and resolves each event argument to its Announcement subclass, completing the publisher↔subscriber graph with zero new syntax and zero drift. It is deliberately advisory (constructor-call arguments resolve; Dynamic/indirect arguments are skipped), not a sound contract. This made the event: keyword track (BT-2437) redundant, and it was cancelled. See docs/beamtalk-language-features.md → "SystemNavigation — Cross-class code queries".

REPL session

bt> Announcement subclass: PriceChanged
...>   field: newPrice :: Number = nil
PriceChanged

bt> a := Announcer new
#Announcer<0.521.0>

bt> a when: PriceChanged do: [:e | Transcript showLine: "now " ++ e newPrice printString]
#Subscription<...>

bt> a announce: (PriceChanged newPrice: 42)
now 42

bt> "Subscribe to the system bus — one feed for everything the system emits:"
bt> SystemAnnouncer current when: ActorSpawned do: [:e | Transcript showLine: e actorClass name]
#Subscription<...>

bt> Counter spawn
Counter        "← the subscription above fires"

bt> "Introspect the wiring — the third navigation sibling (§7):"
bt> a subscriptions size
1
bt> (AnnouncementNavigation default subscribersOf: ActorSpawned) first handlerKind
#do

Error / edge behaviour

bt> a when: 42 do: [:e | e]
// type error: when:do: expects a Class, got Integer (42)

bt> a when: PriceChanged do: [:e | e boom]   // handler crashes on announce
bt> a announce: (PriceChanged new: #{...})
// other subscribers still run; the crash is logged
// (domain => [beamtalk, announcements]), not propagated to the announcer

bt> "dead subscriber process is auto-removed via monitor — no manual cleanup"

Prior Art

SourceModelWhat we take / leave
Pharo Announcements + SystemAnnouncerTyped Announcement subclasses, when:do: / when:send:to:, subclass dispatch; SystemAnnouncer uniqueInstance is core image, the system publishes class/method change events through it.Take: the whole typed-Observer protocol and the it-lives-in-the-core decision. This ADR's central move is recognising Beamtalk is in the same position. Adapt: weak refs → BEAM monitor.
GemStone AnnouncementsSame family, server-side.Confirms the model scales to a shared multi-client image — our exact LiveView situation.
Phoenix.PubSubtopic-string + cluster broadcast over pg2/pg.Take (original design): pg for cluster-wide membership. (Amendment BT-2530: as built, membership is the local ETS table and cross-node delivery is explicit-pid registration — see Layer 1; partition tolerance still to the package.) Leave: topic strings — we want typed events + MRO, not stringly-typed topics.
Erlang gen_eventSerialised handler dispatch in one process.Leave: single-process serialisation is a bottleneck and a shared failure domain; caller-side ETS fan-out + isolated handlers is more BEAM-idiomatic.
Erlang telemetry (ADR 0069)Untyped measurement bus.Leave as the measurement layer; bridge to it from the package. Explicitly not the typed Observer.
ROS typed topics / Akka EventStreamTyped publish/subscribe by message class.Confirms type-keyed (not string-keyed) subscription is the ergonomic choice for domain events.

User Impact

Steelman Analysis

Location fork — the core decision

(a) Pure package (status-quo BT-2193).

(b) All in stdlib.

(c) Layered — runtime bus + stdlib veneer + package extras (chosen).

Substrate fork

Tension points

Alternatives Considered

Covered in the steelman: (a) pure package, (b) all-stdlib, (c) layered (chosen); substrate alternatives ETS-per-announcer, gen_event, telemetry-as-substrate. Also considered and rejected: "do nothing, let each ADR build its own channel" — explicitly the fragmentation this ADR exists to stop.

Consequences

Positive

Negative

Neutral

Implementation

Downstream of acceptance (the /plan-adr step owns the epic + the BT-2193 re-scope):

  1. Phase 0 (napkin): beamtalk_announcements with one Announcement class, Announcer new, when:do:, announce: (async) over a single pg group + monitor cleanup. Prove typed dispatch end-to-end from the REPL.
  2. Runtime bus (full): MRO match, announceAndWait: (sync), when:send:to:, when:doOnce:, fault isolation, sup wiring. EUnit must prove the concurrency properties a REPL demo cannot (the hardest-to-get-right parts): (a) concurrent announce: from N processes against one doOnce subscription → exactly one delivery; (b) subscriber dies before dispatch → no delivery, no announcer crash; (c) bus crashes + restarts → subscriptions survive (heir) and pids that died in the gap are pruned; (d) handler crash under announceAndWait: → caller still returns (DOWN handled), siblings unaffected; plus the MRO-walk microbenchmark backing the cost estimate.
  3. stdlib veneer + introspection: Announcement / Announcer / SystemAnnouncer, the Announcer self-inspection methods, and the AnnouncementNavigation navigator + SubscriptionNode value class (§7) — the navigation pattern is settled by SystemNavigation/ProcessNavigation, so it ships in v1, not later. Registration (generated_builtins.rs, build_stdlib.rs); typed signatures. BUnit.
  4. System events + consumer consolidation: define the system Announcement subclasses; migrate the runtime emit points; update ADRs 0017/0046/0054/0091/ 0092 to subscribe via SystemAnnouncer (and ADR 0092 §8 to point here).
  5. Package re-scope (BT-2193): distributed delivery, RecordingAnnouncer, telemetry bridge; cross-repo CI.
  6. Docs + e2e: language-features chapter, surface-parity, e2e btscript; flip this ADR to Implemented.

Affected components: runtime (new worker), stdlib (veneer + system events), package (re-scoped), docs/tests. No parser/codegen changes.

Implementation Tracking

Epic: BT-2438 — Announcements: Typed Event Substrate (ADR 0093) Status: Implemented (Layers 1–2 + system events + introspection + docs/e2e; Layer 3 package = BT-2193, optional, separate track)

PhaseIssueScope
1BT-2439Runtime bus skeleton + ETS sub table + subscribe + async announce:
1BT-2440MRO subclass matching + per-subscription de-dup
1BT-2441announceAndWait: (sync) + doOnce: + when:send:to: + fault isolation
1BT-2442ETS heir crash-survival re-arm + dead-pid prune
2BT-2443stdlib veneer — Announcement/Announcer/SystemAnnouncer/Subscription
2BT-2444Introspection — self-inspection + AnnouncementNavigation + SubscriptionNode
3BT-2445System Announcement subclasses + runtime emit points
3BT-2446Point consumer ADRs (0017/0046/0054/0091/0092) at SystemAnnouncer
4BT-2193Package re-scope — distributed/RecordingAnnouncer/telemetry bridge
5BT-2447Language docs + surface-parity + e2e btscript; flip ADR → Implemented

Related: BT-2437 (event: emission manifest — separate track, §7). ADR-authoring issue BT-2396 is Done.

Migration Path

Amendments

BT-2454 — true per-instance Announcer subscription isolation

Context. As originally specced (and shipped in Phase 1–2), the subscription table had no announcer dimension: it was keyed by SubRef with a by-class index {Class, SubRef}, so every Announcer new shared one class-keyed bus. A subscription made on announcer A received events announced on announcer B for the same class. This contradicted the intuitive "fresh, independent dispatcher" reading of Announcer new (and Pharo, where each Announcer owns its own SubscriptionRegistry), and was a latent cross-talk footgun between unrelated libraries that happen to share an event class.

Decision: isolate. Each subscription row and by-class index entry now carries an AnnouncerRef dimension (a reference() per Announcer new; the beamtalk_system_announcer atom for SystemAnnouncer and the raw Layer-1 API). Matching (announce: / announceAndWait:) and introspection (subscriptions / subscribersOf: / subscriptionCount, and AnnouncementNavigation's default vs of:) all scope to one announcer.

Why this is cheap and does not reverse §4. §4 rejected a per-announcer process (a central mailbox to deadlock/bottleneck). Isolation reintroduces none of that — it is purely a matching-key change. Dispatch stays caller-side off concurrent ETS reads, the MRO walk is unchanged except for the extra key field, and there is still exactly one shared ETS table pair. SystemAnnouncer falls out as just the atom-keyed namespace; system_announce/2 publishes there.

AnnouncementNavigation becomes a scope-carrying handle (FFI-minted with its announcer, like Announcer carries its ref) so default (system bus) and of: anAnnouncer (that announcer) report different subscription sets — realising the §7 design that was vestigial under the shared bus.

References