ADR-0054 Architecture decision record
The kit ships Machines, and a shipped Machine leaves its parts Open
The kit's only worked Machines lived in examples/: two model Instances, one of them (coding) four workflows deep, written against surfaces three ADRs old, runnable by nobody but the author, and installable by no one — an example is copied, never imported. ADR-0049 had already settled that "a package exports a Machine, nothing beside it" and ADR-0019 that "reusable work is published as npm packages", yet the kit itself shipped none, so the packaged-Machine story had no first instance to hold it honest. The grill of 2026-09-14 replaced the examples with a package, and in doing so found the one part ADR-0049/0051 left a package unable to leave unsaid: the model.
Decision
- The kit ships Machines as
@jr2/machines. A fourth published package beside@jr2/{cli,orchestrator,agent-protocol}(ADR-0043), on the same release train, exporting named Machines from its index:import { task } from "@jr2/machines". It ships Machines and never Workflows — a Workflow is the name an Instance registers a Machine under, and only the Instance'sworkflows/file can do that (GLOSSARY.md, ADR-0009). Rejected:@jr2/workflows— it names exactly the thing the kit does not ship, andworkflowis on Machine's avoid-list for the reason ADR-0049/0050 lean on the distinction. @jr2/machinesauthors against the consumer's@jr2/orchestrator: peer dependencies, no regular ones. Every part a Machine carries is keyed in maps@jr2/orchestratorholds (vocabulary.ts, parts.ts) and inxstate's ownprovideandisMachine; a second copy of either is a Machine whose Menus are empty and whose Gates accept nothing, with no error anywhere.peerDependencies: { "@jr2/orchestrator": <exact kit version>, xstate, zod }states the fact and is the shape a user's own Machine package copies. Rejected: regular deps at the exact version — dedupe makes it work by the accident of equal versions and installs two copies silently on skew.- A shipped Machine leaves its parts Open, and Open widens from the Repo Slot to the Agent's model. ADR-0051's
opensentinel — "a consumer's to bind, withcustomize" — applies unchanged to the one part a package cannot honestly fill:agent({ model: open, instructions }). Thejr2 upwalk reports an Open model beside Open Repo Slots and refuses the converge naming thecustomizeline that binds it; the Agent actor refuses to admit a Turn whose definition is still Open, as the second fence.AgentDefinitionstays the wire type withmodel: string— a Symbol does not ride a Turn — andagent()takes a declaration whosemodelisstring | typeof open. The sentinel becomesSymbol.for("jr2.open"). Rejected: a stock default model — the kit picks a vendor in a published package, and a user who never reads the file spends money on a model they did not choose, silently, which is the failure classjr2 upexists to make loud. Rejected: a Machine factorytask({ model })— ADR-0049 settled that a package exports a Machine and retunes withcustomize, and a factory reopens that. - Open widens to an Effect's url, argv and secret, bound once. A package cannot know the host its
httpEffect calls, the program itscommandruns (ADR-0067), or the secret its composer spends, so it may leave each Open:http({ method, url: open, auth, input, request }),http({ method: "PUT", url: open, body: file(path) }),command(open, { env, pin }), andsecret(open)in place of a secret's name. The url, the argv and the secret's name are the Open parts of an Effect that exist; everything else the definition says is the package's as written — the method, the headers, the auth and the secrets they name, the input,request, the env,pin— and is checked when the Machine is built.secret(open)is taken bybearer,basic(the user or the password), a header's value, and a command's env, as its value or its.path; in a url it is refused when the Machine is built ("leave the whole url Open"), since a url that carries a secret is left Open whole (below). An Effect takes one Open secret: a secondsecret(open)in one definition is refused when the Machine is built ("an Effect takes one Open secret"); the onesecret(open)may stand in several places, a value and its.path. An Effect that names an Open secret is placed as one that names a secret is — a credentialed command in the Sandbox Effector, anhttpwhere its body places it — before the name is known. The composer binds the parts withcustomize(machine, { effects: { <slot>: { url } } }),{ argv }or{ secret }, and an Effect with an Open url or argv beside an Open secret takes one object with both keys ({ url, secret },{ argv, secret }): a url is a string or atextwith a secret in its path, an argv is the usual list, and a secret is a name that obeyssecret()'s own rule — it is the secret's file in the Effector's mount (ADR-0068). The type ofeffectsis the intersection of the Open parts' bindings, so a binding that leaves one of them unbound is a compile error, as an unbound url is; at run time, a binding that leaves one unbound gives an Effect still Open in it, which a latercustomize()may bind. Each is bound ONCE, to a value: never a mapper over the door, so a host is fixed in the definition or bound before any run, and no run chooses one, nor a secret. Binding builds the Effect again with its constructor, with the bound name in every placesecret(open)stood, so a bound part meets every rule the constructor checks — a literal host, no secret in a query, a non-empty argv, a secret's name — and the bound Effect is the one the same definition written with that name gives;jr2 upreads it as written: the placement, the secrets it names, and the Effector's fence (ADR-0068) are the bound ones, and the bound secret is placed only in that Effect's Effector, as a named secret is. Only an Open part is offered: a part the author fixed is no key ofeffectsand is refused at the call, and a part bound once is fixed from then on, so a second composer cannot move it.effectsreaches throughworkspace()'s body,pool()'s worker and a composed Machine'sactors, asagentsdoes, typed off the slot's logic. An Open argv's slots are the composer's, so the load walk counts every pick param as read by it when the Machine is built, andcustomize()runs the walk's pick-param check again once the argv is bound — a param the bound argv does not read is refused there, and a warning it gives for the bound argv is the bound Machine's, as it is the same Machine's written with that argv: the bound Machine gets a config of its own, which carries those warnings (ADR-0049), so a copyprovide()makes of it — a run's — keeps them, and the Open Machine keeps its own. The compiler's check does not reach a bound argv: an Open argv's input is one string per key, so nothing at build or atcustomize()checks that the invoke supplies each slot the bound argv names, and a slot it does not supply faults when the Effect starts — its state'sonError, with nothing run. Thejr2 upwalk reports an Open Effect beside Open Repo Slots and Agents and refuses the converge naming theeffectsline —{ effects: { <slot>: { secret: "<name>" } } }for an Open secret, one object with both keys for an Effect with two Open parts; the Effect refuses to run while Open, as the second fence — its state'sonError, with nothing sent and no ledger row. The Open part is the whole url, the whole argv, or the secret's name. - The per-run Dial is the escape hatch, not the default. A shipped Machine's door may carry
modelandthinkingLevel(ADR-0018's two Dials) and fold them into each invoke's input; the bound definition is whatjr2 uppreflights, the door value overrides it for one run's Turns. Nothing in jr2 changes for this — it is the Machine's own four lines. - The first Machine is
task: one prompt, one Workspace, one human says done. Door{ prompt, branch?, model?, thinkingLevel? }, branch defaulting tojr2/task-<instance id>, where<instance id>is the seed Instance ID of the run (instanceIdinjr2 status), not the run id; the Repo Slots Open as a map (repos: open, ADR-0051) — the consumer names them, and the order istask's own convention, since jr2 gives no slot a meaning: the first is where the coder works, any others are checkouts the first Turn frames for the coder to read; one Agentcoderwith an Open model.workinginvokescoder; itsfinish { summary }parks at thereviewGate (meta: summary, branch, worktree), as does a terminalagent.fault(meta: reason).approvereaches the final state and tears the Workspace down;request_changes { notes }continues the same conversation (continue: true, ADR-0057) — one human steering one Agent wants the Agent to remember what it did, unlike ADR-0049's coder⇄reviewer handoff, which is lossy on purpose. No round cap: the human is the cap. Rejected: a per-run Repo on the door — a packaged door cannot enumerate the Instance's repos, and "a Workflow is a name" reads best when the name means "a prompt against THIS repo". Rejected: a namedtarget: openslot — the body reads the first checkout the handles carry, neverrepos.target, so the name claimed a shape the body did not have and shut out the one thing a task commonly wants beside its repository: another checkout to read. - The Machine does not push. ADR-0005/0053: the Agent holds no credential and the push url is the caller's own spelling with the caller's own credential. The Gate park is the inspection window — exec in, review, push, then
approve, and unpushed commits go with the pod, as ADR-0012 always said. A credentialed push out of the pod is a decision of its own, not this one. examples/retires;templates/defaultis the one Instance in the checkout. The scaffold's model moves fromexamples/startertotemplates/default— the folder documents what an Instance looks like and is whatjr2 initrenders, kept byte-equal to the CLI's inline templates by the existing drift test, since an installed CLI cannot read a repo-root folder (ADR-0043).templates/is plural because a laterjr2 init --template <name>is the obvious next step. The scaffold staysping-only — the@disttier's contract is a fresh instance that runs before any provider or repo exists — andping.ts's prose shows the@jr2/machinesmove as the next step instead of an inlineagent()sketch.examples/codingis deleted whole,jr.tsincluded; An Agent in a Sandbox showstaskin its place.
Consequences
- Every Machine in
@jr2/machinesships with a@kindscenario. These are for end users, and the only proof a shipped Machine works in jr2 is the stock Harness driving it in a real Sandbox.features/kind-instanceregisters each one the way a consumer would —customize(task, { repos, agents })over the tier's fixture repo and scripted model — and the scenario walks its Gates. The socket-free mechanics test beside the Machine covers the defaultpnpm -r testgate; it does not stand in for the scenario. @jr2/machinesjoins thePUBLIClist inpublish.test.tsand the guard loop inscripts/dist-publish.sh; the@disttier publishes it to verdaccio, so its pack andfiles:list execute there even though no@distscenario runs it.- GLOSSARY.md gains Open as a term, and the Workflow entry says the kit ships Machines. Eleven earlier ADRs cite
examples/; the citations are retargeted in place. - A
customizethat binds a model is now the common consumer line, not the exotic one;jr2 up's refusal for an Open Agent prints it, as it does for an Open Repo Slot and an Open Effect (itseffectsline). - No Machine in
@jr2/machinesleaves an Effect Open yet;taskcarries no Effect. - Two copies of one Machine spend two teams' secrets: each composer binds the Open secret, or a url that carries one in its path, with its own. A labelled form for two Open secrets in one Effect can be added later without changing the unlabelled one.
- The
triaged-taskmechanics test went withexamples/coding, and the cross-Machine conversation pin it exercised went with ADR-0057; the@kindconsulted/advisedfixtures hold Instance Harness placement.