Skip to content

ADR-0033 Architecture decision record

A Machine declares the input that starts it ​

POST /workflows/:name/runs takes a JSON body and hands it to the machine as xstate input — unvalidated, and undescribed: the module contract is export const machine alone (ADR-0011/0015), so nothing at runtime says what a run of a given workflow should be started with. The Console's start form (ADR-0032) forced the question, but the gap predates it — jr2 run has the same blindness, and a typo'd input surfaces as a confusing mid-run failure instead of a refusal at the door. Meanwhile every event a machine accepts already carries a zod schema, attached to the machine itself (ADR-0015's vocabulary-on-the-machine). The input that starts a machine is the one piece of its vocabulary that pattern missed.

Decision ​

  • The schema rides the machine, like the rest of the vocabulary. jr2Setup.createMachine accepts an input (a zod object) and attaches it the same way event defs ride today (WeakMap-keyed on machine.config, so a .provide() clone keeps it — ADR-0011). input is deliberately xstate's own word for what a machine receives at creation — the authoring surface teaches nothing new.
  • A door that no one reads is refused. The setup() of xstate keeps an input in the config of createMachine, but it does not read it. TypeScript accepts that input. Thus a Machine that setup() made and that has a zod input has no door. A run starts with any input, and the Console start form is only a field for JSON. jr2 up and the publish door use the same walk (doorRefusals, ADR-0069). The walk refuses a registered Machine if its config has a zod input and no door is attached to it. The refusal line gives the fix: make the Machine with jr2Setup. If the Machine takes no workflow event, leave out events (ADR-0015). The walk tests the attached door, not the factory. Thus a workspace() or pool() wrapper with its own door passes. A .provide() clone keeps the config of its original, so the walk gets the same result for the clone. A Machine with no input, or with an input that is not zod, can use setup(). The ping Workflow that jr2 init writes uses it. The walk checks the registered roots only, because the input of a nested Machine is never the door (see below). workspace() refuses a body with a zod input when you call it. It reads the config and the attached door, so it also refuses a body that setup() made. Considered and rejected:
    • Refuse all setup() Machines. This adds work to the first Workflow and gives nothing.
    • Refuse at boot. Then an Assembly that is already published stops the Orchestrator. The walk refuses only the next publish.
    • Take a zod input on a setup() Machine as the door at load. The loader can read that input. But then there are two ways to declare one door. Also, the zod schema stays in machine.config, and a schema must not go into the fingerprint and serialization paths that read the config. And that Machine does not get the Vocabulary checks of jr2Setup.
  • A wrapper declares its own door; it never borrows its child's. workspace(body, { input, spec }) and pool(worker, { input, … }) each take an input of their own, and neither propagates one upward. The reason is the same in both, at different strengths: a wrapper does not feed its child what a caller sent. pool is the sharp case — a worker is fed per-item, never the run body, so propagating its schema would demand fields the pool never passes and strip the pool-level fields cap/itemInput read. workspace is the mild one — its body gets the run input plus the injected workspace handles, so the body's contract is the door PLUS a field no caller can send (the handles do not exist until a Sandbox is provisioned and attached). Propagating it would make createMachine({ input }) mean "my input" on a bare machine and "my input minus what my wrapper injects" on a wrapped body — a context-dependent contract, and a trap: a body that declared its input honestly, handles included, would 400 every valid run start. The vocabulary does not propagate either — event names resolve against the Machine that invoked the actor, so a body keeps its own defs and nothing is merged upward (ADR-0011, ADR-0049). The door is one more part a Machine carries and scopes to itself. The composition the body needs a name for is exported instead: Workspaced<T, Slot> = WithoutEmptyIndex<T> & { workspace: WorkspaceHandles<Slot> }, the handles keyed by the Repo Slots the body names (ADR-0050, ADR-0051). A door with no fields gets the same name: z.infer of z.object({}) is Record<string, never>, an index signature that says each key holds never, and intersected with the handles it makes each assign to the body's context a type error that does not name the cause. So Workspaced removes a string index signature whose value is never, and only that: named fields beside it stay (HostInjectedInput & … keeps instanceId), and an input that is a real map is not changed. A separate name for the empty door was the alternative; it was rejected because the author then chooses between two names for one statement, and finds the second only after the opaque error. A body that still declares a schema is refused by workspace() with a message naming the fix: nothing would ever serve or validate it, and a contract nobody enforces is worse than none.
  • The door types the mapping, and constrains the body. WorkspaceOptions.spec reads its input from the declared schema (z.infer), so a workflow states its shape once and the workspace mapping is checked against it — the drift this ADR exists to close, closed in the one place that used to re-declare the shape by hand. The same schema checks the body, in one direction only: the body may not demand more than the wrapper will hand it, which is Workspaced<door, slots>; demanding less is safe, because it is fed a superset. So a body reading a field the door never carries is a build error at the workspace() call instead of an undefined mid-run. The guard counts the raw door as provided beside Workspaced<door, slots>: for the door z.object({}) the two differ, and a body that writes z.infer<typeof door>, alone or with the handles, stays legal. Comparing against Workspaced alone was the alternative; it was rejected because it refused those bodies, which compiled before Workspaced removed the empty index signature. One field sits outside that check, named as HostInjectedInput: the host adds instanceId to the input of the machine a run starts with, so a root-placed wrapper hands its body the door, the handles, and that — while a nested one does not, and no type can see which. It is not door material either (no caller sends it, start overwrites it after the parse, nothing serves it), so widening the door would be the wrong fix. The guard takes the permissive answer: it counts those keys as provided, so a body may declare the field honestly. Counting them as provided rather than subtracting them from what the body demands is deliberate — subtraction would drop the keys from the comparison and with them two cases the check owns: a body that mistypes instanceId, and a body whose input is a union, which subtraction compares on its members' shared keys alone. The typed wrapper also checks the seam above it — a parent invoking a nested workspace() has its input mapper checked against that wrapper's door, and gets the body's output typed back. With no schema declared the door is permissive, so there is nothing to infer and the mapper's argument is unknown — an honest "jr2 does not know", not any; a wrapper fed by something other than a caller (a pool worker) states what it is fed by annotating the parameter, as PoolSpec.cap/itemInput do. spec takes the same permissive answer as the guard: its input is the door plus HostInjectedInput, so a spec gives each run a branch from instanceId with no cast, and a nested wrapper whose spec reads it needs a parent that gives it. Partial<HostInjectedInput> was the alternative; it was rejected because each root spec would then write a fallback for a value that is always there. The input of the wrapper stays the door alone, so no parent must send instanceId. For the door z.object({}) that input is {}, the door without its Record<string, never>, so a parent can give instanceId to a nested spec that reads it; with the raw z.infer it could not, and the nested Workspace then pushed each run to fix/undefined. z.infer<…> & Partial<HostInjectedInput> was the alternative; it was rejected because the index signature makes instanceId never there too. A per-run Repo Slot's mapper keeps the door alone, because a url does not come from a run's id.
  • The schema is structure, so it is open. Workflow-detail JSON (GET /workflows/:name, the address ADR-0032's negotiation kept) serves it as JSON Schema — same band as the Machine doc, and what drives the Console's start form before any token is entered. The submit stays authenticated; schema open, trigger guarded.
  • A declared schema is enforced at the door. POST /workflows/:name/runs validates the body and 400s naming the accepted shape — parity with what Gates already do for deliveries. No schema declared → accept anything (today's behavior), and the Console falls back from a generated form to a raw-JSON textarea.
  • The door takes an idempotency key, out of band. A start may carry one in a header, never in the body (a body field would be one every door had to reserve); a repeat with the same key answers with the run the first one made. A dispatch Effect sends its ledger row's id (ADR-0071), so a start whose answer was lost is sent once where it may have arrived (ADR-0067) and makes one run. jr2 run and the Console send none.

Considered options ​

  • export const input beside export const machine. Rejected: it re-opens what ADR-0015 closed — a workflow's self-description split across module exports, when the machine object already carries every other piece of the workflow's self-description.
  • workspace propagates its body's schema onto the wrapper (the shape this ADR first took, before pool forced the question and workspace turned out to be the same species). Rejected: the wrapper's pass-through is not the body's input — the body also gets the injected workspace field — so the propagated schema is right only for a body that under-declares its own contract. Two ways to declare one door also cost more than the line of ceremony they save: an author reading a wrapped workflow could not tell, without opening the factory, which declaration the runtime serves.
  • Derive from xstate's types.input. Impossible: TypeScript types are erased; there is nothing at runtime to serve or validate with.
  • Constrain the body by pinning its TInput slot (body: StateMachine<…, Workspaced<door, slots>, …>). Rejected as unsound, not as style: StateMachine's members include methods, method parameters are bivariant, and a body demanding more than the door provides therefore compiles — the one case the constraint exists to catch. The shipped formulation intersects the body with a conditional guard whose failure branch is an object type keyed by the sentence to read, so the diagnostic names the fix rather than printing a structural diff.
  • Require a schema on every workflow. Rejected: it taxes the zero-ceremony hello-world for a guarantee only input-taking workflows need. Absence is permissive, and permissive is what the wire already was.

Consequences ​

  • Both entry points sharpen for free: the Console generates its form from the same JSON Schema jr2 run can later use to refuse bad input client-side before the POST.
  • The open band grows a schema and nothing else — the same class of thing as the Machine doc it sits beside; ADR-0014's projection is untouched.
  • Validation parity means error parity: a bad run start and a bad gate delivery now fail the same way, a 400 naming what is accepted.

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