Skip to content

ADR-0011 Architecture decision record

Control events are workflow-defined and delivered by closure, not routed ​

Two decisions, out of the jr-parity design exercise: jr2 ships the event mechanism and zero events, and an event binds to a Machine state through the actor that exposes it — delivery is a closure created at invoke time, so no routing layer exists anywhere.

defineEvent: mechanism, not policy ​

defineEvent({ name, input, audience?, semantics? }) produces a pure-data definition: a name, a zod input schema (flat tagged objects, never oneOf — the ADR-0006 encoding rule), an optional audience tag (agent | external | any; unset, each writing of the event belongs to the party its state, or the nearest state above it, invokes — ADR-0015), and an optional semantics tag (ack, or the reserved poll; a def declares no output — ADR-0006). It is a pure factory — no import-time side effects, no global registry. Because actor inputs are serializable (ADR-0007) they carry event names, so resolution needs a name→def scope; that scope is per-Machine: jr2Setup takes the defs as values and attaches the vocabulary to the machine (ADR-0015 — discovery reads it there; the module contract is export const machine alone), and gate/agentRun resolve names against the Machine that invoked them — self._parent.logic, public xstate API — never a run-wide set. Names are local to their Machine: coding's approve and release's approve may differ, and one run may hold both, because a Machine nested inside another resolves against its own defs and no map is ever merged. That is what makes a Machine composable by plain invoke (ADR-0049): the importing Machine neither re-declares nor sees the nested one's events, and workspace()/pool() propagate nothing. An unlisted name fails at invoke time with an error naming the Machine and its declared set. The attachment is keyed on the machine's config, which xstate's .provide() passes through unchanged, so the host's per-run provide and a test's .provide() both keep it. We rejected a global name registry (import = registration): it couples every workflow in an instance — and any future npm-distributed one — through one namespace, and attribution can't be inferred at import time. We also retired the run-scoped set this ADR first specified (RunBinding.events, the root's vocabulary): it made every nested Machine's events the root's problem to re-declare, and turned a same-name-different-payload pair into a collision that the per-Machine defs had already avoided.

defineEvent also carries the TypeScript side: jr2Setup derives the machine's event union from the defs — one source of truth, no schema/type drift, and the machine stays fully typed for xstate tooling.

A def's input is what its sender supplies, and nothing the Machine adds. Where a pick starts work, fromPick(def, fill) carries those params into the invoked Effect's input by name, and fill adds the Machine's part; the load walk refuses a param nothing reads and a fill that overwrites one (ADR-0067, ADR-0015).

There is no jr2-blessed event vocabulary. request_review, review_verdict, approve are the coding workflow's words; another workflow defines others. jr2 owns only definition, transport, validation, and delivery.

Agent side: invoke-scoped registration, delivery via sendBack ​

On start, the agentRun actor registers, for its instance id, a toolset — the menu derived from its invoking state's transitions (ADR-0015) — whose handlers close over that invocation's sendBack; on stop it deregisters. A tool call → validate against the named schema → sendBack a typed event, plus its sender → the event lands on the state that invoked the agent (bubbling to ancestor states per statechart semantics — a machine can handle a report_blocked-style event once at its root). The binding is the closure.

  • No routing. There is no instance→actor table and no workflow-side forwarding boilerplate; delivery works at any nesting depth (workspace wrappers, pool children) with zero authored code.
  • State-scoped menus for free. A transition swaps actors, which swaps registrations; the surface an Agent sees is exactly the live registration.
  • One catch point. A call for an unregistered iid (settled run, exited state) is rejected at the demux.

Transport: the Agent's registration is read from the Sandbox (ADR-0013). The Orchestrator speaks no MCP: it serves GET /agents/:iid/surface + POST /agents/:iid/events under the Sandbox token, and the pod's Harness reads them through the Custodian on localhost, which holds the token, and presents the surface to its model as the Menu (the Harness re-reads per Submission, so the iid in the path is all the addressing needed). Lookup, validation, delivery, and lifecycle stay implemented once, in the registration table.

External side: the same primitive over HTTP (gate), and each gate is a resource ​

gate is the symmetric actor for every non-agent caller — humans (jr2 send, a UI), webhook translators, CI, other systems. "Human" is policy, not mechanism, so the actor is not named for one caller. Each invocation is an addressable gate: input is { gate?, meta? } — gate an optional authored id (the id is derived when absent — below), meta serializable caller/integration context (PR URL, title); its accepted set derives from the gated state's transitions, its ancestors' and its plain children's (ADR-0015). Registration is per-gate: GET /runs/:runId lists open gates ({ gate, accepts (names + schemas), acceptsNow, meta } — acceptsNow the names the run would take from this Gate now, read the way an Agent's Allowed picks are, ADR-0029), POST /runs/:runId/gates/:gate/events validates the body against the named schema and delivers via sendBack into the gated state — or refuses a send the run would not take (ADR-0013) — and leaving the state destroys the gate. Gate ids are run-scoped by mechanism (the same system→run mapping that scopes event resolution), so gate: "F-12" in two concurrent runs cannot collide.

Gate-as-resource is forced by concurrency: several children park simultaneously (three features in humanReview is the normal case), all accepting approve — a run-level POST is ambiguous. And every real external surface already thinks in gates: a CLI listing shows pending decisions and acts on one; a UI inbox renders one card per gate (schemas drive the forms, meta drives the copy); a forge webhook translator finds its gate by matching meta (e.g. prUrl), which is why meta exists. Generalizing beyond humans also closes a hole hard-coded event types left: a push seam like "a work-source webhook wakes discovery" is just an idle state holding a gate accepting work_ready (the wake seam of ADR-0017). Two obvious later additions, both deferred: a cross-run inbox (GET /gates), and CLI segment-matching over derived gate ids (--gate F-12 resolving an unambiguous segment, git-style like run-id prefixes).

The gate id is derived; authoring one is the exception. Absent an authored gate, the id is the gate actor's own path below the run root, and jr2Setup's menu-derivation walk names an unnamed gate invoke with its state key path so the leaf segment is readable — F-12.body.humanReview, not xstate's 0.body.humanReview default. This is unique wherever concurrently live siblings have distinct actor ids — the invariant any correct fan-out already maintains, because xstate keys children by id (a duplicate silently shadows the children-map entry and breaks xstate.done.actor.<id> correlation). Authored ids exist for meaningful flat names (jr's feature id) and collision on them is a loud invoke-time error. Derivation runs at actor start, not the input mapper — deterministic from structure, so recomputation on every (re)start is restore-stable, and one mechanism serves jr2Setup and plain-setup machines alike (the walk contributes id quality only, never correctness). Rejected: a workflow-computed id as the default (gate: context.feature.id) — it quietly violated the address doctrine (Consequences below), making the gate id the one caller-facing address a workflow computed by hand, and a machine with a static id ("humanReview") was correct standalone but a latent, timing-dependent collision once composed under a pool, firing only when two children park concurrently. Also rejected: deriving only in the walk (a second dialect and an error case for plain-setup callers), and xstate's default invoke ids as the name (invoke-index noise no caller cares about).

The full symmetry: agents deliver through their per-iid registration (via the Custodian); everything else delivers over HTTP (per-gate registration). Two dialects, one primitive, zero routing. Internally that is literal structure: one registration table (address → { accepted event defs, deliver closure, meta, sender }) that agentRun and gate register into, with the agent surface and the gates HTTP API as dialect adapters over it — a future caller class (a Slack bridge, an email reply) is another adapter, not a third mechanism. The table is implementation structure, not vocabulary — workflows speak only defineEvent / agentRun / gate.

Every delivered event carries its sender. The table writes sender: { party: "agent" | "gate", id } on each event it delivers, after the schema parse and from the registration alone: id is the Agent's slot, or the Gate's invoke id (static in the Machine, the same after a restore — not the caller-facing gate id, which a workflow may compute). It is plain data on the event, so it rides the mailbox, a delayed event the run host persists beside the snapshot, and a restore; a restored registration writes it again from the persisted input and the invoke id. The Machine's own events (raise, sendTo itself) carry none. defineEvent refuses an input that declares sender, and both surfaces refuse a body that carries it (ADR-0013): no caller and no def can write it. What an author does with it is ADR-0015's.

CANCEL stays reserved as the run-level infra interrupt. jr's "exit 3 and let the human drive the bash script" becomes "park in a durable state that advertises its human surface."

Static imports; live objects constructed per-invocation from input ​

Everything above is reachable by plain import. We rejected two alternatives:

  • Injected closures / a defineWorkflow(kit) wrapper — xstate's provide() fills only the machine it is called on; it cannot reach actors inside child machines, so host-side injection cannot compose past one level (the finding that retired the templates-with-injected-providers model — see ADR-0015).
  • Adapter singletons — a module-global "current adapters" holder cross-contaminates in-process multi-instance use and turns mocking into global state. (The demux registry above is not this: it is rendezvous keyed by globally unique ids, not a swappable adapter.)

The doctrine that makes static import work, sharpening ADR-0007: actor logic is code; everything live is constructed per-invocation from serializable input — the Harness client from an endpoint, the kube client from ambient config. Tests mock by machine.provide() at the layer under test.

Consequences ​

  • Addresses are computed by jr2, never by the workflow — iids always (ADR-0016): fresh per invocation by default (the lossy handoff), or <run>/<machine actor path>/<agent> plus its epoch under continue: true (ADR-0057); gate ids derived from the gate's actor path by default, authored only to give external callers a meaningful name. Durable handles live in the host ledger (ADR-0016), not in machine contexts.
  • Test stubbing happens at the wire, not in the actor. The e2e tier (ADR-0010) hosts a wire-compatible stub Harness beside its orchestrator fixture (admits the agent, holds the stream open, never acts). An endpoint is just a URL, so agentRun keeps a single code path and cannot tell it is talking to a fake. Scope: workspace-less test workflows only, which pass endpoint directly in run input — e2e drives their Machines by playing the Harness's Menu against /agents/<iid>/* with no cluster. Workflows that invoke workspace() always get real Sandboxes (the data plane is never faked — ADR-0009); their e2e tier requires kind.
  • Unsolicited mid-turn steering remains the residual hard case (ADR-0002/0006).

The docs of Kit version 0.5.1. Each page is also at its .md URL.