ADR-051 — In-bot event-driven primitives (emit / wait)
Status: proposed → Brick 1 implemented (2026-06-30). The intra-run emit/wait pair backed by a run-local reliable event registry, with a mandatory wait timeout, ships first. External-event resume of a parked run (via the ADR-046 trigger spine) and a cross-process paused_waiting_event status are documented follow-ons.
Context
iterion is already heavily event-driven at the platform layer — the ADR-046 trigger spine (pkg/eventbus + pkg/trigger) routes (event filter) → (bot launch), supervisors (pkg/supervise) are reactive addEventListeners on a running agent, and a human node is already an await externalEvent (it parks the run and resumes when the answer arrives).
What is missing is event-driven control flow inside a single .bot: a node that can emit an event and a node in a parallel branch that can wait for it. This is the in-bot analog of EventEmitter.emit/on and await. It turns the .bot graph from pure dataflow into a reactive coordination graph.
The natural temptation is "make it like JS" (an event loop + microtask queue + shared mutable heap + callbacks closing over mutable scope). That model fights two guarantees iterion deliberately keeps (see ADR-050 / docs/dsl-totality-and-tc.md): write-once immutability (Layer C mutable state is a non-goal — it breaks resume / reproducibility) and resource-bounded termination. The right model is actor / CSP / reactive-streams, not the JS event loop:
| JS | iterion idiom |
|---|---|
emitter.on("x", cb) | a wait node / subscription edge — not a closure |
| mutable payload shared between handlers | immutable event payload → handler produces a new artifact |
await fetch() | a wait node — parks until the event, bounded by a timeout |
| global mutable state across handlers | absent by design (Layer C non-goal) |
Decision
Add two node kinds to the DSL, backed by a run-scoped reliable event registry — deliberately NOT the lossy cross-run pkg/eventbus (which is at-least-once and drops under back-pressure; correct for cross-run notification, wrong for in-run coordination).
emit node
emit ping:
event: "ready"
with: { value: "{{outputs.producer.n}}" }Publishes a sticky event named ready into the run registry with an immutable payload (resolved from the with: mapping using the existing DataMapping/ParseRefs machinery). Sticky = a wait that arrives after the emit still sees it (the registry records fired events, so emit/wait are not order-fragile). Non-mutating, no LLM, no shell.
wait node
wait for_ready:
event: "ready"
timeout: "30s" ## MANDATORY — the bornage
output: ready_schema ## optional: types the received payloadBlocks its branch until ready is emitted, then completes with the event's payload as its output ({{outputs.for_ready.value}}). The timeout: is mandatory — the "no silent infinity" invariant, exactly mirroring C097 on unbounded loops. On timeout the node fails resumably (a future brick may route to a timeout edge). Non-mutating.
Run-scoped registry (the substrate)
A mutex-guarded structure on runState (pkg/runtime/engine.go):
type runEvents struct {
mu sync.Mutex
fired map[string]map[string]interface{} // event name → immutable payload
waiters map[string]chan struct{} // event name → close-on-fire signal
}emitrecords the payload under the event name and closes the waiter channel (creating a pre-closed one if none exists) → sticky + broadcast.waittakes the (possibly already-closed) channel andselects on it vs the mandatory timeout vsctx.Done(). A closed channel reads immediately, so a late waiter never blocks.
This reuses the existing fan-out goroutine-per-branch model: branches already share runState by pointer under locks, so the registry rides the same discipline. emit/wait are non-mutating, so the single-mutating-branch workspace-safety rule is unaffected.
Scheduler constraint (resolved — slot released on park). A parked wait now releases its fan-out semaphore slot and reacquires it on wake (pkg/runtime/fan_out.go branchSlot, pkg/runtime/special_node.go executeSpecialNodeForBranch), so an emitting sibling can always obtain a slot even under max_parallel_branches: 1. release/acquire are idempotent on a per-branch held flag, so the launchBranches defer still releases exactly the final hold. A ping-pong fan-out (each branch emits and waits) completes deterministically on a single slot — regression: TestFanOutPingPongSingleSlot. The mandatory wait timeout remains the ultimate bound. (Originally documented as the deferred "releasing the slot on park" scheduler refinement.)
Diagnostics (C196–C198 — the next free block after C195)
- C196 (error) — an
emit/waitnode with noevent:name. - C197 (error) — a
waitnode with notimeout:(the mandatory-bound invariant; the C097 analog). - C198 (warning) — a
waiton an event noemitin the workflow produces (it can then only ever time out) — the C098 analog (a dangling/unreachable event). Symmetrically warns on anemitnowaitconsumes (dead event).
What stays out (by design / staged)
- No mutable shared state. Payloads are immutable; a handler produces a new output/artifact. (Layer C remains a non-goal.)
- No unbounded wait.
timeout:is mandatory; the run stays budgeted and resumable. - External events / cross-process resume (follow-on). A
waiton an event sourced outside the run (a webhook, a sibling run finishing,iterion emit) parks the run as a newpaused_waiting_eventstatus (mirroringpaused_waiting_human) and resumes via the ADR-046 spine with a new resume-parked-run effect alongside the existing launch effect. Deferred to keep Brick 1 self-contained and deterministically testable in-process. Releasing the semaphore slot on park (scheduler refinement) — deferred.Done — a parkedwaitreleases + reacquires its fan-out slot (see the scheduler-constraint note above).
Consequences
- The
.botgraph gains genuine reactive coordination (CSP channels between parallel branches) without a new transport — it rides the existing fan-out +runState+DataMappingseams. - Every guarantee from ADR-050 is preserved: immutability (payloads are copied-in), bounded termination (mandatory timeout + budget/fuel), resumability (the registry is reconstructible; Brick-1 intra-run waits are in-process).
- The model is consciously actor/reactive, not JS — which keeps it composable with everything the engine already promises.
References
- Substrate: pkg/runtime/engine.go (fan-out +
runState+ node dispatch), pkg/eventbus (the cross-run lossy bus this deliberately does NOT reuse for in-run coordination), pkg/trigger (the platform spine for the external follow-on). - Demonstrator:
examples/events/pingpong.bot+e2e/events_emit_wait_test.go. - Doctrine: docs/dsl-totality-and-tc.md, ADR-050, ADR-046 (trigger spine).
