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.createMachineaccepts aninput(a zod object) and attaches it the same way event defs ride today (WeakMap-keyed onmachine.config, so a.provide()clone keeps it — ADR-0011).inputis 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 aninputin the config ofcreateMachine, but it does not read it. TypeScript accepts thatinput. Thus a Machine thatsetup()made and that has a zodinputhas no door. A run starts with any input, and the Console start form is only a field for JSON.jr2 upand the publish door use the same walk (doorRefusals, ADR-0069). The walk refuses a registered Machine if its config has a zodinputand no door is attached to it. The refusal line gives the fix: make the Machine withjr2Setup. If the Machine takes no workflow event, leave outevents(ADR-0015). The walk tests the attached door, not the factory. Thus aworkspace()orpool()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 noinput, or with aninputthat is not zod, can usesetup(). ThepingWorkflow thatjr2 initwrites uses it. The walk checks the registered roots only, because theinputof a nested Machine is never the door (see below).workspace()refuses a body with a zodinputwhen you call it. It reads the config and the attached door, so it also refuses a body thatsetup()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
inputon asetup()Machine as the door at load. The loader can read thatinput. But then there are two ways to declare one door. Also, the zod schema stays inmachine.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 ofjr2Setup.
- Refuse all
- A wrapper declares its own door; it never borrows its child's.
workspace(body, { input, spec })andpool(worker, { input, … })each take aninputof 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.poolis 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 fieldscap/itemInputread.workspaceis the mild one — its body gets the run input plus the injectedworkspacehandles, 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 makecreateMachine({ 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.inferofz.object({})isRecord<string, never>, an index signature that says each key holdsnever, and intersected with the handles it makes eachassignto the body's context a type error that does not name the cause. SoWorkspacedremoves a string index signature whose value isnever, and only that: named fields beside it stay (HostInjectedInput & …keepsinstanceId), 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 byworkspace()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.specreads itsinputfrom 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 isWorkspaced<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 theworkspace()call instead of anundefinedmid-run. The guard counts the raw door as provided besideWorkspaced<door, slots>: for the doorz.object({})the two differ, and a body that writesz.infer<typeof door>, alone or with the handles, stays legal. Comparing againstWorkspacedalone was the alternative; it was rejected because it refused those bodies, which compiled beforeWorkspacedremoved the empty index signature. One field sits outside that check, named asHostInjectedInput: the host addsinstanceIdto 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,startoverwrites 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 mistypesinstanceId, 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 nestedworkspace()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 isunknown— an honest "jr2 does not know", notany; a wrapper fed by something other than a caller (a pool worker) states what it is fed by annotating the parameter, asPoolSpec.cap/itemInputdo.spectakes the same permissive answer as the guard: itsinputis the door plusHostInjectedInput, so aspecgives each run a branch frominstanceIdwith no cast, and a nested wrapper whosespecreads it needs a parent that gives it.Partial<HostInjectedInput>was the alternative; it was rejected because each rootspecwould then write a fallback for a value that is always there. The input of the wrapper stays the door alone, so no parent must sendinstanceId. For the doorz.object({})that input is{}, the door without itsRecord<string, never>, so a parent can giveinstanceIdto a nestedspecthat reads it; with the rawz.inferit could not, and the nested Workspace then pushed each run tofix/undefined.z.infer<…> & Partial<HostInjectedInput>was the alternative; it was rejected because the index signature makesinstanceIdneverthere 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 staysauthenticated; schema open, trigger guarded. - A declared schema is enforced at the door.
POST /workflows/:name/runsvalidates 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
dispatchEffect 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 runand the Console send none.
Considered options
export const inputbesideexport 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.workspacepropagates its body's schema onto the wrapper (the shape this ADR first took, beforepoolforced the question andworkspaceturned out to be the same species). Rejected: the wrapper's pass-through is not the body's input — the body also gets the injectedworkspacefield — 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
TInputslot (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 runcan 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.