ADR-0015 Architecture decision record
Workflows are authored through jr2Setup; vocabulary and agent menus derive from the machine
The jr-parity exercise produced a working model but a bad consumer API: mechanism plumbing (manifests, tool lists, event-union bookkeeping) dominated the workflow. The redesign grill (2026-07-13/14, full record in docs/design/workflow-api/) settled the split: the consumer writes pure workflow — states, transitions, prompts, policy — and jr2 absorbs everything else through convention. This ADR covers the Authoring surface; ADR-0016 covers the agent turn; ADR-0017 covers the run loop.
The root of @jr2/orchestrator is the Authoring surface
A workflow authors against one package: jr2Setup, defineEvent, agent, workspace, the Effects, customize and defineConfig all import from @jr2/orchestrator. Its root exports the Authoring surface (GLOSSARY.md) and nothing else.
- The rule. A name is on the surface when an author writes it in a workflow module or in
jr2.config.ts. No other name is. A name that an author only gets by inference stays off the surface: the event union thatjr2Setupderives, a Gate's input, an Agent's Frame. Thegateactor is off the surface too.jr2Setupregisters it, and a state invokes it assrc: "gate", so no author writes the value. A type is on the surface when an author must write its name, for exampleWorkspaced,HostInjectedInput,AgentDefinition, and the types of the parts thatcustomize()takes.JR2Configis on the surface too: thejr2.config.tsReference documents its fields, and an author names it to type a config that is built outsidedefineConfig(). - Named exports only.
src/index.tsnames each export (export { a } from …,export type { T } from …), in groups by topic, and has noexport *. Thus an Assistant that reads the file sees the full surface, and each change to the surface is a line in a diff. - A test pins it.
test/authoring-surface.test.tsmakes sure thatindex.tshas noexport *, that the root's runtime values are equal to a sorted list in the test, and that the type names in theexportlists ofindex.tsare equal to a second sorted list. A name that is added or removed fails the test until the list changes. The same test makes sure that@jr2/machinesand each Instance-shaped folder import the root only. - The kit's own packages import
@jr2/orchestrator/internal. It holds the surface and all that the kit shares: the run host, the HTTP API, the Harness wire, the Custodian, the Effector wire, the cluster names. It is not for authors, and it has no stability promise: any name in it can change in any release. A Machine that the kit ships (@jr2/machines) and each Instance-shaped folder in the repo import the root only. Theexportsmap has.,./internaland./tsconfig.instance.json, and nothing else. Thus an Instance that imports a deep path, such as@jr2/orchestrator/src/run-host.ts, does not resolve it, intscor in Node.
jr2Setup: an xstate setup() analog, not a DSL
jr2Setup({ types, events: [defs], actors, actions, guards }) is statically imported and returns xstate's public SetupReturn — .createMachine() yields a plain StateMachine, Stately-inspectable and .provide()-testable, with no jr2 runtime needed to construct it (proven by compiled experiment against xstate 5.32.2; consumer surface has zero casts). It:
- injects the mechanism events (
agent.fault,workspace.lost) into the event union and derives the workflow event types from the zod defs — hand-writtenEventFromunions retire; - pre-registers the jr2 actors (
agentRun,gate) with typed inputs; - takes the defs as values, which lets it validate at
createMachinetime that every event key appearing anywhere in the machine maps to a def — closing xstate's nested-ontypo hole (unknown keys in nested states typecheck silently upstream) with a load-time failure; - attaches the vocabulary to the machine (
vocabularyOf(machine), a WeakMap keyed onmachine.config— the object xstate's.provide()passes through, so a clone keeps it, and the returned machine stays bit-identical). Theexport const eventsmanifest retires, and the workflow module contract shrinks toexport const machine(revising ADR-0011's named-exports contract): the defs ride the Machine, and the actors that need them resolve names off the Machine that INVOKED them (ADR-0011, ADR-0049) — the host copies nothing at registration, and the only other reader is the Machine doc the Console renders. ADR-0011's anti-global-registry argument is preserved — attribution flows through the machine, per-Machine by construction.
events is optional, and so is the whole argument: a Machine with no Vocabulary leaves events out, or calls jr2Setup(), and its event type is the Mechanism events alone. Such a Machine still uses jr2Setup when it declares a door (ADR-0033), because xstate's setup() does not read the input of createMachine. An empty Vocabulary is the default, not readonly EventDef[], so the type of event does not widen to every event name. A required events was considered and rejected: events: [] tells the reader nothing, and the compiler gave a missing events as a large structural diff of SetupReturn, not as the name of the missing key.
Agent menus and gate accepts derive from the machine
A state that invokes an Agent slot derives the workflow events its Turn may pick: its own transitions, its ancestors' (bubbled, per statechart semantics), and the transitions written on the states below it — its modes. xstate hands an event to the deepest active state that handles it, and a false guard there falls through to the parent, so a pick written on a child is one the Turn can take while that child is active. The descent stops at a child that invokes another Agent slot (a nested Turn: its picks are its own) or a gate (its transitions are the Gate's accepted set), and leaves out everything below that child; a child that runs anything else stays in. A state that invokes gate gets its accepted set from its own and its ancestors' transitions and those written on its plain children, by the same descent: position gives a Gate the writings below it that no other party answers for, as it gives an Agent its modes (the Pool's wake Gate, on its root, takes the writing on parked). The consumer names neither; MCP appears nowhere in workflow code. Mechanics: the vocabulary derivation is static, in jr2Setup.createMachine — a config walk wraps each invoke's input to append the derived names, so names still ride serializable input and the ADR-0007 restore path and invoke-time validation are unchanged. The same walk unions the derived sets of every state that invokes an Agent of one name into that Agent's Menu — its tools, fixed for a conversation so the provider's prompt cache survives a change of state. The walk reads transition keys and so cannot see guards, which is why what a Turn may pick is not static on top of it: agentSurface asks the invoking machine's guards, and the Turn is told its Allowed picks — the derived set, less every event that the live snapshot, in its active mode, would not take (ADR-0029 owns the rationale). Authoring is untouched by the split: a workflow names no tools in either leg. The same walk feeds jr2 visualize ("this state's agent can call X, Y"). Dotted names (agent.*, workspace.lost, xstate.*, after) are mechanically excluded.
A transition belongs to the party that its state, or the nearest state above it, invokes. The party is the Agent slot or the gate that state invokes, or both when one state invokes both; root transitions, with no such state above, belong to both. That is who may send an event whose def leaves audience unset (defineEvent({ audience?: "agent" | "external" | "any" }), no default):
| Transition written on | Unset audience: who may send |
|---|---|
| A state that invokes an Agent, or a mode below it | That Agent |
| A state that invokes a Gate | The Gate's caller |
| A state that invokes both | Both |
| A state that invokes anything else (a child Machine, a wrapper body, any other actor), or nothing | The party of the nearest invoking state above; none above means both |
So child or sibling says only "same Turn or not" (ADR-0024): an in-Turn Gate's caller sends the Gate's events and not the Agent's picks, and a nested Agent does not get the outer Agent's picks. Write an audience only to give a pick to the other party, or to hold a root pick back. An explicit audience overrides by kind over the whole reach: agent reaches every Agent whose reach holds a writing of the event, external every Gate's, any both — so submit: "any" lets an in-Turn Gate's caller send the Agent's pick, and report_blocked: "agent" keeps a root pick from the Gates. A def means one thing: its description must be true at every writing its party can reach, and two meanings are two defs. The reach itself is unchanged by the rule — own and ancestors, plus the states below with no party-invoking state between (an Agent's modes, a Gate's plain children) — and the rule only filters inside it, so no case widens who can send a pick; a test scans every shipped Machine and fixture for it, both the derived names and the writings each party may take at run time. The walk computes every party's seat — per state, who may send what — and the Machine's doc carries it; jr2 up prints it for every registered Machine and every Machine it composes, and the Console shows the same lines, folded beside the workflow's diagram (structure, so tokenless too). An explicit accepts: on a Gate's input remains an escape hatch.
sentBy(id): a writing that declines other senders. Every event a party delivers carries its sender (ADR-0011): sender: { party, id }, the Agent's slot or the Gate's invoke id. jr2Setup registers one guard, jr2.sentBy, and the author writes it on a writing as guard: sentBy("coder") — a named guard with params, alone or inside and([...]). It is true for an event whose sender.id is id and for an event with no sender (the Machine's own: raise, sendTo itself, a child's sendParent), and false otherwise, so xstate's own selection moves outward to the next writing of the name. It can only make a writing decline; it never gives a writing to a party — position does. id is an Agent slot or a Gate's invoke id of this Machine, and a sentBy that names neither is refused at load, like an unknown event key. Under or() or not(), or in an author's own guard that reads event.sender, it works at run time but the walk cannot read it. A .provide() test sends as a party by writing the field: { type, sender: { party, id } }.
A delivery never runs another party's writing. Audience says who may send a name; xstate selects which writing runs, deepest active first, whoever sent it. So at delivery the real event — its payload and its sender — goes through xstate's own selection (getTransitionData) before the Machine sees it, and if a writing selected is one the sender may not take by the position rule above, the event is refused: an Agent's pick is answered moved: false and not delivered (ADR-0029), a Gate's caller gets 409 with what the Gate takes now (ADR-0013). The lists ask the same question the same way, payload-blind: an Agent's Allowed picks and a Gate's acceptsNow are the names whose selection, for an event that carries the sender and with every guard that reads the payload counted as passing, moves something and holds only writings the sender may take. A guard that reads the payload is unknown there and passes the party question; it is never the fail-open of a party check skipped, because delivery asks again with the real payload. The payload-blind selection is a copy of xstate 5.32.2's (transitionNode, StateNode.next, evaluateGuard, the members of and/or/not), pinned against getTransitionData in the default test run — the canary for an xstate bump. Without a sentBy, nothing misroutes: the shallower party is refused while the deeper writing is active, and the walk warns (below). When the deeper party runs BELOW an Agent's state — a Gate, a nested Agent — the Agent's state is not at Rest, so the Agent's picks are refused before the selection is asked (ADR-0024); the selection decides the Gate's caller's sends there, and every case with the Agent's state at Rest.
Load checks on modes and audience. createMachine refuses a registered event that no state handles: it is in no derived set, so it is on no Menu and no Gate. It refuses a parallel region below a state that invokes an Agent — the Agent's state itself being parallel included, since its regions are below it: the shape is reserved for picks the Agent does not wait for, which jr2 does not build, and background work goes above the Agent's state. It warns — the Machine stays legal; the doc carries the warning, jr2 up prints it and the Console shows it with the seats — about a mode that nothing enters (not initial, and no state the Machine can enter has a transition into it or a state inside it — so neither the mode's own initial nor its transition into itself counts: its picks are on the Menu and never allowed; a transition into a history state enters that history state's default target, shallow or deep, or its parent's initial state when it names none, and a default target the walk cannot resolve is its own warning); about a Gate above an Agent's state that takes an unset-audience writing the Agent reaches (the one narrowing of the audience rule an author may not expect); about two parties' writings of one name that can be active together; about one party's writings of one name that mean two things; and about two shapes below an Agent's state that keep it from Rest, so every pick of the Agent waits (ADR-0024): a Gate whose answer has no target (the Gate stays open after it), and a long-lived invoke — one that runs until its state exits, not a promise, an Effect, a Gate, an Agent or a child Machine. A child with no onDone that strands the Agent in it — a one-way mode — is legal and silent on purpose, an Effect child included.
Load check on the output of a run. xstate v5 gives a run one output: the output at the root of the Machine. The output of a final state directly below the root goes to that root output as event.output, and to nothing else. Thus a Machine with an output on such a final state and no root output ends with no output, and types.output does not stop it. It was the most frequent error in the Machines that Assistants wrote, and an Assistant then told its user a result that the run did not have. Thus the load walk refuses that shape: createMachine and the parts walk at jr2 up and at the boot, so a Machine built with xstate's own setup(), and a child Machine, are refused too. The refusal names each such final state and the two fixes: an output at the root from context, or output: ({ event }) => event.output at the root, which passes on the output of the final state. A final state inside a compound state is legal: its output is the event.output of the onDone of that state. A Machine with no output anywhere is legal: its result is in its context, and it tells nothing false about an output. Considered and rejected: a warning (a run with no output is never what the author meant, and a warning does not stop the false result); and a root output that jr2 adds when there is none (it would make jr2 Machines behave differently from xstate Machines, and a Machine that a different xstate host runs would lose its output again).
Load checks on Effects (ADR-0067). createMachine refuses a state below an Agent's state that invokes something and that nothing enters: what it invokes never runs (a mode nothing enters is only the warning above). Both checks read one reachability walk, history defaults included; where a history default the walk cannot resolve may enter such a state, the walk does not know, and it warns instead of refusing. Both checks look below a state that invokes an Effect or a child Machine, and stop at one that invokes another Agent or a Gate. Where a pick enters a state that invokes an Effect, every param of the pick must be read by something — the check is per state, not per invoke — by the input of any invoke of that state, an Effect's or not, so a second invoke beside the state's work counts (fromPick reads each param that is a key of the Effect's input, a command's argv slots, and each one its fill reads; on an invoke that is no Effect it reads every param, and on a command whose argv is Open it reads every param too, since the argv's slots are the composer's — customize() runs this check again when it binds the argv and refuses there a param the bound argv does not read, and gives the bound Machine the warnings the walk gives for that argv (ADR-0054); a plain mapper is probed with a recording event), by the entering transition's guard (inline, named, or inside and/or/not), or by an assign on the entering transition or on the entry of a state it enters — else the Agent supplies it and nothing uses it: refused. A pick enters what xstate enters: its target, the target's ancestors below the transition's domain, and their initial descendants — and what an eventless always on a state it entered enters next, in the same macrostep with the same event, whose guard and actions count as the entering transition's. So a pick that targets a compound state enters the child that invokes, a pick that targets a child enters the ancestor that invokes, a pick that targets a state whose always moves on enters where it moves, and a transition inside the invoking state does not enter it. A pick is a writing an Agent may send (the position rule above); a Gate's event is a human's, and its params are not checked. The walk does not carry a pick's params across a timer: where an after on a state the pick entered moves on to a state that invokes an Effect, the Effect's input and every guard and action from the timer on read the timer's event, so only a guard or assign before the timer reads them — a param that must cross is stored with assign on the pick's transition, and one nothing stores is refused. A pick that targets a history state enters what was last active, which the walk cannot know: where that may be a state that invokes an Effect, the pick's params there are unchecked, and it warns. A guard and an assign are pure by contract, so the probe runs them; it runs no other action. TypeScript does not catch a fromPick param that is no key of the input: it accepts an extra key on a spread. It refuses a fill that sets a key that is also a param (the Machine's value would drop the Agent's), and a fromPick of a pick other than the event that enters the state. Code the probe cannot or does not run may read a param, so it gives a warning, never a refusal: a mapper, guard or assign that throws in the probe, and — where a param is left unread — any other action on the entering transition or on the entry of a state it enters, named in the warning. Its params are unchecked. It warns where an Agent's state answers on a mode's entry (answer) and an Effect below it has no onDone: the Effect settles in place, that mode is not entered again, and the Agent reads the Effect's output, not the answer. A pick that enters no Effect, directly or across a timer, is not checked for its params.
Two parties' writings of one name that can be active together — one above the other on one path, or in parallel regions — where the deeper writing (each other region's, for parallel regions) does not decline the other sender with a
sentBythe walk can read. xstate would select it for the other party's event, so delivery refuses that event while both are active. The warning names both writings and shows the corrected line, with the guard on the DEEPER writing, because xstate selects deepest first and only a declining deeper writing lets selection move outward:"approve" on "s2.release" (Gate "review-1") and on "s2.release.work.draft" (Agent "coder") can be active together; Gate "review-1"'s "approve" would run Agent "coder"'s writing, so jr2 refuses it while both are active. Make the deeper writing decline other senders: draft: { on: { approve: { guard: sentBy("coder"), target: "#s2.doneB" } } }A root writing is both parties', so the root-writing shape — a root
cancel, a Gate below it, the Agent'scancelin a mode — is the same case: the line goes on the mode's writing, and the Gate's caller'scancelthen runs the root writing. The Agent's writing above a party seated BELOW the Agent's state — a Gate, a nested Agent — is not the case: while that party runs, the Agent's state is not at Rest, so every pick of the Agent is refused whatever its guard, and asentByon the deeper writing would change nothing; the walk does not warn there. Siblings (a mode and a Gate's or another Agent's sibling state) are not the case: neither writing is ever selected for the other, and delivery answers a send there like any pick the state does not take. A Gate whose invoke id the walk derived is named so in the line; giving its invoke anidnames it. AsentByunderor()ornot()leaves the warning in place.One def, one meaning. One party's writings of one name on one path with different targets: its sender reads one description for both, and only the deepest active writing runs. The warning says to make the def's description true at each writing or to give them distinct names. xstate's own transition
descriptionrides the Machine doc beside each writing.
Known limits, not built: two states on one path that invoke one Agent slot share one sender id, so sentBy cannot tell their Turns apart; and a child Machine that forwards a party's event to its parent (sendParent) hands it the child's sender, whose id names a slot or Gate of the child — if the parent has one of that name, sentBy matches it. Only author code forwards an event.
Considered and rejected for modes: a Menu per mode (the tools block would change inside a conversation and bill its history again — ADR-0029), and refusing picks written below an Agent's state at build (that shape is a mode).
Considered and rejected for the audience: a default of any with a convention to tag the sensitive events (an in-Turn Gate's caller could then send every pick of the Agent above it unless each was tagged agent, and a nested Agent got the outer Agent's picks); a fixed default of agent (every Gate event then needs a tag, and the nested leak stays); and a knob on the Gate (accepts: "own"), which fixes the Gate side only. The rule above reads the transition's position, not a mark written in it, so it is not the per-transition demarcation rejected below.
Considered and rejected: per-transition demarcation (xstate transitions have no typed metadata slot for it without inventing jr2-only config inside the transition table — a DSL by the back door) and purely structural own-transitions-only derivation (breaks ADR-0011's blessed handle-report_blocked-once-at-an-ancestor idiom). Position stays the default and the owner of every writing. sentBy is not demarcation: it is an ordinary xstate guard, written only where a writing must decline other senders, and it cannot give a writing to anyone.
Why there is no injection model (the retired ADR-0003)
jr2's first composability story — "workflows are templates of injected providers": Machines reference their moving parts as named xstate slots, filled at deployment via provide({ actors, actions, guards }), with noop defaults — was tried and retired (its ADR, 0003, is deleted; this section is its record). It never composed: xstate's provide() fills only the machine it is called on and cannot reach actors inside child machines, so host-side injection cannot compose past one level — and the host has injected nothing since ADR-0011's static-import doctrine ("actor logic is code; live things are built per-invocation from serializable input"). Workflows author machines against statically-imported mechanisms. What survives of the idea: the kernel "everything pluggable is an xstate actor", and .provide() as the unit-test seam. The mock-vs-real swap it wanted lives at the wire instead (ADR-0011's stub Harness). GLOSSARY.md's Template and Provider entries retired with it.