iterion

ADR-046 — Event-driven runs: a unified trigger spine (board events first)

Status: accepted (phases 1–4) (2026-06-25) — shipped end-to-end, local + cloud-ready: the spine (pkg/trigger + pkg/eventbus); board events (board-mode promote); a direct-mode launcher over runview.Service; run-completion chaining (run.finished/failed/cancelled); scheduled (in-process Scheduler over schedule-kind subscriptions, the local twin of cloudsched); git-forge events on the bus (the shared webhook launch tail emits a SourceForge event, observational via the launched_run_id marker so it can’t double-launch); and custom ingress (POST /api/v1/triggers/emit injects a SourceCustom event onto the spine — the first-class extensibility point for arbitrary external systems). The studio Automations view (/triggers, gated on triggers_enabled) lists and manages every subscription by repo/by bot with a create form per source. Staged follow-ons: the forge cutover (spine becomes the forge launcher, inline path retired behind a parity flag), forge-derived subscription provisioning, and dispatcher EngineRunner convergence.

Context

Iterion could already launch a bot run from several triggers, but each was a siloed implementation with its own config model, storage, and launch path:

The costs: three non-converged launch paths (RunRun, runview.Launch, Runner.Dispatch); an iterion board event could only fire a bot via the 30s poll; no run-completion → run chaining; pkg/cloudsched existed but never fired; and no first-class custom-integration trigger beyond the single generic webhook.

Decision

Two composing layers, plus one canonical event channel.

  1. Capability stays where it is. bundle.Invocation remains the bot-authored “what surfaces can fire me” — no repo/tenant/cron knowledge enters a manifest. InvocationKindBoard gains an optional board: block (on / to_states / all_labels) with fail-fast parse validation.

  2. A new binding layer — pkg/trigger. trigger.Subscription binds (event filter) → (bot launch into a target): one row per (tenant, repo, bot, invocation-kind). The pure trigger.Matcher is the union of every legacy family’s allowlists (sources/kinds/actions/repos/ authors/labels/subject-states), so each old config maps on without losing fidelity. SubscriptionStore mirrors forge.RepoIntegrationStore (in-memory for local single-host, Mongo for cloud), with ListByRepo / ListByBot powering the “by repo / by bot” surfaces and ListCandidates the evaluator hot path.

  3. A new internal event bus — pkg/eventbus. Bus{Publish, Subscribe} with two impls: InProcBus (local, lossy fan-out à la runview.EventBroker) and NATSBus (cloud, a new ITERION_EVENTS stream / iterion.events.* subject — deliberately separate from the iterion.queue.runs work queue, because events are at-least-once fan-out notifications, not exactly-once locked work). The same trigger.Evaluator consumes the bus identically in both modes; only the Bus impl is injected differently.

The packages avoid an import cycle by layering: eventbus imports trigger (for Event/Matcher); trigger imports neither eventbus nor runview. The evaluator’s Handle(ctx, Event) error matches eventbus.Handler, so the wiring layer (pkg/server) connects them; the board source publishes through a tiny local trigger.Publisher interface that Bus satisfies structurally.

First source — iterion board events (the proof)

A native-board card created/moved/labeled fires a bot without the 30s wait:

Wired in pkg/server (StartTriggerCoordinator, reached by both iterion studio and iterion dispatch); discovery-driven via cli.buildLocalTriggerStore (a bot opts into event-driven promotion purely by adding a board: block — zero engine/CLI edit). REST CRUD at /api/v1/triggers, gated by server_info.triggers_enabled.

Why not …

Consequences / staged follow-ons (same spine)

Each later source = “a source adapter that publishes a trigger.Event + an effect choice (promote-card vs direct launch)”:

Key files

New: pkg/trigger/{event,subscription,store,memstore,mongostore,evaluator, launcher,board_source}.go, pkg/eventbus/{bus,inproc}.go, pkg/server/{trigger_coordinator,triggers_routes}.go, pkg/cli/trigger.go. Modified: pkg/bundle/manifest.go (InvocationBoard), pkg/queue/nats/nats.go (ITERION_EVENTS), pkg/dispatcher/manager.go (Refresh nudger), pkg/server/{server,server_info}.go, pkg/cli/{studio,dispatch}.go.