Skip to content

ADR-0049 Architecture decision record

A Machine carries its parts and composes by invoke ​

A workflow could not use another workflow's Machine as a child without re-declaring the child's events at its own root (the triaged-task example documented the workaround), and a Machine had no way to travel: its Agents lived in the Instance's roster by name and its Sandbox Image in the Instance's images/, so a packaged Machine depended on someone else's config holding the right strings. The grill of 2026-09-06/09 settled composition and packaging as one decision, because they are one problem: what a Machine depends on that is not inside it.

Decision ​

  • Composition is Machine composition. A workflow module imports another module's exported Machine and invokes it as an xstate child (actors: { deep, quick }, invoke: { src: "deep" }). One run, one snapshot, one run id; the child's Gates list under the parent's run and its emits ride the same feed. There is no sub-run and no invoke-by-registered-name: a nested Machine is reached by import, never by the name jr2 run uses. Rejected: Temporal-style child workflows with their own run ids — a new actor kind, cross-run correlation, and cancel propagation, for no requirement that needs a second run id. A run that STARTS a run is not composition: it is the dispatch Effect (ADR-0071), by the name jr2 run uses, and the two runs are independent. Two uses, two mechanisms.

  • Everything a Machine depends on rides the Machine, scoped to it. Vocabulary already did (ADR-0011, per-Machine since this decision). The two remaining dependencies join it:

    • An Agent is an actor slot. jr2Setup({ actors: { researcher: agent({ model, instructions, … }) } }), invoked as src: "researcher" with { prompt, …dials } as input. agent(definition) is agentRun closed over one definition and branded with it; agentRun and agent: "name" leave the authoring surface. The typo check is xstate's own src typing — a slot the Machine does not declare is a compile error with nothing added — and Stately shows researching → invoke researcher instead of a uniform agentRun. Two Machines in one run may both carry a researcher; each resolves against its own implementations, and the Instance ID's actor path keeps their conversations apart.
    • An Effect is an actor slot too (ADR-0067), and its plain-data parts ride the slot: the kind, where it runs, a command's argv, an http Effect's method, fixed host and the names of the secrets it names — or the open sentinel where a package left the url, the argv or a secret's name for its composer (ADR-0068). jr2 up reads them without running anything, and a secret named there is what places it.
    • The Sandbox Image is a workspace() option, static: workspace(body, { input, image, repos, spec }) (repos is ADR-0051's, and follows the same rule). image is a string in one of two shapes — a file: URL (import.meta.resolve("./image"), a docker context the module ships) or a registry ref — or absent, which keeps ADR-0037's fallback. It moved out of the per-run spec because jr2 up must find it statically. It is never persisted: the provisioning state re-reads it off the Machine on restore.
  • The definition rides the Turn. The admission body carries the slot's definition; the Harness runs what it was handed and re-reads it per Submission as before. The Instance roster (agents/, then config.agents), its ConfigMap, and JR2_AGENTS_JSON retire — a flat roster cannot hold two researchers, and a Machine edit already needs jr2 up to publish the Assembly again, so the ConfigMap bought nothing a Machine-carried definition loses. Placement (ADR-0031) reads workspace: "none" off the slot's definition.

  • Parts resolve at invoke time through the live actor's logic, never a build-time closure. agentRun finds its definition in the logic it was invoked as; provision reads the image off the wrapper's parts map keyed on machine.config. This is the one rule that lets xstate's provide() and customization coexist, and the same rule ADR-0011 adopted for Vocabulary.

  • Customization is customize(machine, parts), a plain function in the family of workspace() and pool(), returning a plain StateMachine. parts has the declaration's own shape, recursively partial: { agents?: { <slot>?: Partial<AgentDefinition> }, effects?: { <slot>?: { url } | { argv } | { secret } }, image?, user?, repos?: { <slot>?: Binding }, actors?: { <child>?: parts } } — the two image seats together, since ADR-0005 gives the User Container the same static option, and the Repo Slots beside them, since ADR-0051 makes a Repo a workspace() slot a composer binds. effects binds an Effect's Open url, argv or secret name, once — an Effect with an Open secret and an Open url or argv takes one object with both keys, { url, secret } or { argv, secret } (ADR-0054): the slot's logic is built again with the bound part and substituted as an Agent override is. Internally an Agent override is xstate's machine.provide({ actors: { researcher: agent(merged) } }), with the override layered over the stock definition; a child override is the same call one level down, recursing — each level is exactly the one-level reach ADR-0015 found provide has, held by the composer who owns the child object, never host-side injection. jr2's wrappers are transparent: customize(research, { agents }) reaches the body through the body slot (and pool()'s worker, at any depth), so a consumer never writes body. The images and the Repo bindings are the parts provide cannot carry (xstate copies only implementations, and every part is keyed on the config it passes through by reference), so those fields clone the wrapper's config and rebuild it with the same implementations, re-attaching what the original carried. A bound argv's load warnings are such a part too: customize() rebuilds the Machine the author wrote the same way, with the vocabulary, the seats and the door it carried and the warnings the walk gives for the bound argv, so no attachment is keyed on anything but the config. Only DECLARED parts can be retuned, and the compiler is what says so: the slots are read off xstate's own TActor parameter, so an Agent, a Repo Slot, or a child the Machine does not carry is a type error (ADR-0050), and the runtime refuses the same call naming the Machine's own slots. Rejected: a .with() method on the machine — it needs a jr2-owned machine type over xstate's, which ADR-0015 avoided, and it vanishes after any .provide(); a callable-machine hybrid — verified to work, reads as a trick.

  • A package exports a Machine, nothing beside it. The door (ADR-0033), the vocabulary, the Agents, and the image all ride the exported object. The three uses:

    ts
    export const machine = research;                                                            // as-is
    export const machine = customize(research, { agents: { researcher: { model } }, image });   // retuned
    const deep = customize(research, { agents: { researcher: { model: opus } } });              // nested twice,
    const quick = customize(research, { agents: { researcher: { model: haiku } } });            // differently
    export const machine = jr2Setup({ events: [route], actors: { triager: agent(triager), deep, quick } }).createMachine(…);

    A local workflows/*.ts is written exactly the same way; there is no package-side API.

  • The wrapper's body is a named slot (invoke: { id: "body", src: "body" } over setup({ actors: { body } })), and pool()'s worker likewise. That is what lets provide, customize, Stately, and the jr2 up walk reach it uniformly.

  • A Part is a key of actors. An agent(), an Effect (ADR-0067) or a workspace() written inline on an invoke.src is refused at build (jr2Setup.createMachine) and at load (the walk), with the slot form in the message. xstate gives an inline src a generated key (xstate.invoke.<n>.<state id>) that moves when a state is renamed, so the ledger, jr2 up's report and the Machine doc would name the Part by a number, and customize() — provide, keyed on actors — could not reach it at all. A plain child Machine written inline is not a Part: the walk descends it, with no customize() route below it.

  • jr2 up walks the registered Machines — implementations.actors, descending into child Machines — to collect models to preflight and file: image contexts to build. A context is keyed by its content digest (the same digest ADR-0038 tags by), taken over its copy in the staged Assembly, the bytes the Orchestrator holds (ADR-0069), so the host at jr2 up and the Orchestrator agree without a path table even where staging dropped a .env or a node_modules the folder holds. Repos are slots on the same walk (ADR-0051): a bound slot's url is collected, so its Repo resource is warm before a run asks; an open slot nobody bound is refused, naming the Machine, the slot, and the customize line that fixes it; a per-run slot is the run's business and the walk leaves it alone.

Consequences ​

  • Agent identity moves from the Instance to the Machine. GLOSSARY.md's Agent entry restates: an Agent is a part of a Machine, not an Instance-scoped roster entry; customize is a definition-level act by the composer, so ADR-0018's identity-vs-Dial line is untouched — a Turn still sets model/thinkingLevel and nothing else.
  • A Workflow is a Machine registered under a name. The "wired to its Agents and config" clause retires: the wiring is inside the Machine.
  • defineAgent retires in favor of agent(); AgentTurnInput.agent goes; deriveMenus keys on agent-slot logic instead of src === "agentRun"; the unit-test seam becomes provide({ actors: { researcher: fake } }).
  • Only declared parts can be customized; none can be added. A consumer who needs a third Agent composes a new Machine. A workspace() that declares its Repo Slot map Open (repos: open, ADR-0051) has declared every slot as the composer's to name, so the map they write is the declaration, not an addition.
  • import.meta.resolve stays in any Machine that ships a context; there is no other way for an ES module to name a folder it owns.
  • The ledger records which slot a Turn ran as, not the definition's text. If per-Turn auditability of instructions is ever needed, the admission marker can carry a definition digest.
  • jr2 dev's reload cache-busts the workflow module only; an edit to an imported Machine module is not picked up until the next full reload. Known, unaddressed here.
  • A workflows/ file that is both discovered and imported by another registers as its own Workflow too; a Machine meant only for composition lives in a _-prefixed file or outside workflows/.
  • A conversation belongs to one Agent slot in one Machine instance by construction — the continued iid is <run>/<machine actor path>/<agent> (ADR-0057) — so no continuation can cross personas. What a continuation does NOT check is that the definition behind the slot is the one that started it: a run whose instructions were edited before a redeploy continues, which ADR-0030 deliberately allows (shape decides restorability, not behavior). A definition digest on the ledger record would refuse exactly that, so it is not taken.

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