ADR-0013 Architecture decision record
The Agent reaches its Machine through a container it cannot read
An earlier cut put the Agent's tool surface on the Orchestrator (/mcp/:instanceId), which meant an Agent must dial into the control plane to drive its Machine. Validating the workspace slice on kind (2026-07-12) showed that leg did not exist and could not be safely built as specified: nothing passed a callback URL into a Sandbox, jr2 dev bound loopback, and — the real problem — an Agent that can reach the delivery API can deliver to any registration in its run, including its own human-review Gate. The fix is not to hide the API from the Agent. Working tools give the Agent code execution in the Harness container, which shares the pod's network namespace, so it can reach any address the pod can. The fix is where the credential lives: in a container of the same pod that the Agent executes nothing in — the Custodian (ADR-0059). The Orchestrator speaks no MCP, and keeps one HTTP surface over the registration table it already has (ADR-0011).
The enabling fact: the Harness reads its Menu per Submission
Each Submission, the Harness reads GET /agents/<iid>/surface: the Agent's Menu, which it presents to its model as tools named mcp__jr2__<event> (shipped instructions and the printer's prefix-stripping depend on that name; ADR-0027 states it as explicit jr2 code, never written by users). The Harness names the iid itself, so nothing has to learn which turn is live — no push channel, no long-poll, no sandbox → active turn index, no second inbound port on the pod. The same read carries the Turn's Allowed picks, which the Harness states in the Briefing; the Menu itself is fixed for a conversation (ADR-0029), so no list_changed push is needed either (ADR-0006).
Decision
The Custodian carries the Agent's Menu, and holds the credential that makes it work. It is a jr2-composed container in every Harness pod (ADR-0005). The Sandbox token is mounted into it as a file, and into no other container. The Harness container holds the token's Stand-in,
JR2_SANDBOX_TOKEN=jr2-held-JR2_SANDBOX_TOKEN, and reaches the Orchestrator only through the Custodian's control listener on127.0.0.1:8081. The Custodian forwards four routes, with the token put in place of the Stand-in, and answers every other route itself:GET /agents/<iid>/surface the Menu → the Orchestrator POST /agents/<iid>/events a pick → the Orchestrator GET /agents/<iid>/deliveries/<id> an outstanding pick's answer → the Orchestrator POST /fetch the ask (ADR-0053), for THIS pod's Sandbox → POST /sandboxes/<name>/fetch anything else 404So the Agent's sole control-plane peer is a container it can reach but whose credential it cannot read. That is what makes ADR-0006's "the Agent never steers the workflow" enforced rather than advertised.
The Harness presents the Menu to its model itself. There is no MCP server and no MCP client.
menu.tsturns each accepted event into a Menu item, with the event's JSON Schema as its parameters, and turns a pick into onePOST …/events— and, for an outstanding pick, the polls of its delivery, inside the same tool call. The engine runs one model answer's calls in the model's order (ADR-0027), and only the first pick of a model answer runs. It imports nothing from pi:menu-tools.tsgives each item to pi as a tool. A harness that only speaks MCP would get a second presenter over the samemenu.ts, not a second Menu. The receipt comes back as prose (ADR-0024). A 404 on the surface is a Turn that is over, and it is never prompted (ADR-0026).The Orchestrator hosts no MCP, and judges every pick. The registration table stays the one internal primitive; it keeps two thin HTTP surfaces, neither of them MCP:
# human / webhook / CI — the Gate resource of ADR-0011, authenticated GET /runs/:id → open gates: accepts + schemas + acceptsNow + meta [Instance token] POST /runs/:id/gates/:gate/events → validate + deliver, or 409 [Instance token] # the Agent's, through its pod's Custodian GET /agents/:iid/surface → Menu + Allowed picks + schemas + semantics [Sandbox token] POST /agents/:iid/events → validate + deliver → the receipt, or 409 [Sandbox token] GET /agents/:iid/deliveries/:id → an outstanding pick's answer, long-polled [Sandbox token]The receipt is
{ delivered, event, moved, turnComplete, allowed, deliveryId }. A pick the workflow is still running when the POST answers — it moved the workflow to work inside the Agent's state (ADR-0024) — isoutstanding, anddeliveryIdaddresses its answer:GET /agents/:iid/deliveries/:idholds up to about 25 s and answers eitheroutstanding(ask again) or the receipt withanswered, the Allowed picks read after the route, and the answer. A child that replied inside the pick's own send and left the workflow at Rest makes the POST receipt itself theansweredone. A stopping or parked Orchestrator answers the poll 503, never 404, which says the Turn is over; the delivery is scoped to the Sandbox its registration had when the pick was made, because the answer outlives a registration that a route out of the Agent's state ends. A pick while one is outstanding, or while the Agent's state is busy, is answered409 { refused: "outstanding", outstanding?, allowed: [] }and nothing is delivered. Lookup, validation, delivery and lifecycle are implemented once, in the table. The token's scope, the live registration, and the event's name and payload are judged here and nowhere else; the Custodian forwards and decides nothing about a pick. Neither surface takes a body that carriessender: the table writes it on every delivered event (ADR-0011), so a client that posts it — the Harness,jr2 send --input— is answered 400. The gates surface answers{ ok: true }when the run took the send, and only then; a send the run would not take now — its selection holds another party's writing, or no writing would move on this payload (ADR-0015, ADR-0029) — is answered409 { error, acceptsNow }and nothing is delivered.jr2 send --gateprints that error, which names what the Gate takes now, and exits 1.Bearer tokens, because the boundary is otherwise theater. The Harness container shares the pod's network namespace, so an Agent can
curlthe Orchestrator directly; a per-pod NetworkPolicy cannot distinguish it from the Custodian. Only authentication closes this.- Sandbox token — minted per Sandbox at provision (a signed Sandbox name, so re-provision after a restart yields the same token), delivered as a Secret mounted into the Custodian container only. User
harness.env/envFromland on the Harness container, never the Custodian. Authorizes exactly: deliver to agent registrations whose Sandbox is this one, and ask for this Sandbox's fetch. Never a Gate (they are not on that surface at all), never another Sandbox. The Stand-in the Harness holds is refused on every route. - Instance token — the human/CLI credential for Gates and run control. Minted into an in-cluster Secret at
jr2 up; the CLI reads it over the kube API (RBAC is the gate) while port-forwarding the Service (ADR-0019). - The Instance-token door. The Instance token may also read any Agent's surface and deliver to it —
GET /agents/:iid/surface,POST /agents/:iid/events— because it is the operator. That is the operator speaking AS the Agent: it reads the Agent's Menu and Allowed picks and its picks are judged as the Agent's — they carry the Agent's sender (ADR-0011) — and no audience filter applies, so it can send a pick no Gate's caller may send (ADR-0015). It is deliberate — it is how the e2e tier stands in for an Agent — and it adds no party: neitherjr2 sendnor the Console offers it, and a Sandbox token never opens it for a registration that is not its own. - An agent registration records its Sandbox ambiently:
agentRunresolves the enclosingworkspace()and records the same deterministic name the token was minted for (ADR-0016), so forgetting to pass it is unrepresentable (the pre-ambient version shipped exactly that footgun:sandboxomitted, every tool call 403'd — fail-closed but silent). A run-scoped token instead of a Sandbox-scoped one would be genuinely exploitable: derivable iids plus readable ticket ids would let one feature's coder inject areview_verdictinto another feature's reviewer.
- Sandbox token — minted per Sandbox at provision (a signed Sandbox name, so re-provision after a restart yields the same token), delivered as a Secret mounted into the Custodian container only. User
Reachability, narrowed to one caller. The Orchestrator is always in-cluster (ADR-0019), so the route home is simply Service DNS, which the pod's maker writes into the Custodian's configuration — the Orchestrator at a Sandbox's provision,
jr2 upfor the Instance Harness. The Harness is told the Custodian's loopback address (JR2_CUSTODIAN_URL) and nothing else.A Machine-answered tool result is the held answer, for every pick that runs work. No def declares it: a pick whose target runs something inside the Agent's state is outstanding until the workflow is back at Rest, and the Harness polls its delivery inside the one tool call (ADR-0002, ADR-0024). The gates surface answers
{ ok: true }for a send the run took and 409 for one it would not — external callers need no receipt.deferredis retired (ADR-0006).pollis still in the model and the surface listing still ships each def'ssemantics; registering apolldef fails loudly as unimplemented — a silent downgrade toackwould be a lying tool contract — and the Harness refuses a turn whose surface offers one.
Considered options
- Agent talks to the Orchestrator directly (the original cut, plus a callback URL). Rejected: the credential lands in a container where the Agent has code execution, so the Agent can deliver
approveto its own human-review Gate — it merges its own PR. The tool menu is advisory once you hold the key to the API. - A jr2-owned Adapter container that serves the Menu over MCP on loopback and holds the token. It meets the claim, but a whole container, image and package exist to translate two HTTP calls into MCP and back. Model keys need a container the Agent executes nothing in anyway (ADR-0059); one container can hold every credential, and the translation then has no reason to live outside the Harness.
- Translation inside the Harness, with the credential there too. Rejected — same container, same code execution, same credential. The translation is inside the Harness now; the credential is not.
- The Orchestrator serves MCP, and the Custodian forwards it. Rejected: it keeps the MCP transport and its session handling in the control plane and on both ends, to carry two plain HTTP calls.
- A uniform
/registrations/:addressAPI for every caller. Rejected: it dissolves the run-scoped, discoverable Gate resource ADR-0011 designed on purpose (a webhook translator finds its Gate bymeta.prUrl;GET /runs/:idlists open decisions) in exchange for aesthetic symmetry.
Consequences
- The operator needs no change. ADR-0001 made sidecars generic container fragments the operator schedules without understanding; the Custodian is exactly that.
kubeSandboxcomposes it (plus the token Secret, written right after the CR with the CR as its owner from birth, so Kubernetes reaps it with the Sandbox and no path leaks it — ADR-0001). - Nothing in jr2 speaks MCP. The Orchestrator, the Harness and the CLI carry no MCP dependency.
- One container holds the Sandbox token and every model key. A fault in the Custodian's engine is a fault in the one container that holds both. ADR-0059 records the trade.
- The
@kinde2e tier owns the pod → Orchestrator leg — the pod's Harness actually calls its tool through the Custodian, so a broken leg is a red test, and a Harness container that posts a pick straight to the Orchestrator with the Stand-in it holds is refused. The mechanics tier plays/agents/:iid/*from the host, which is honestly simulating the Harness's Menu, not an Agent. - NetworkPolicy is a second layer, not the control. It bounds where a pod may talk; only the token bounds what it may do.
jr2 upconverges ingress policies that admit only the Orchestrator to a Harness pod (ADR-0058); the Orchestrator itself is left reachable, because a Custodian in every Harness pod must reach it and no policy can tell the Custodian's packets from the Agent's. - The Harness wire is authenticated in the other direction. The Orchestrator bears a token derived for the placement on every Harness call, and the Harness holds only its digest (ADR-0058). Same signed-name idea, reversed caller.
- Open: authn for non-kube callers. The Instance token rides kube RBAC for anyone with cluster creds; what an ingress-exposed jr2 Application uses for human/webhook callers without them (
--urlmode) is out of scope here (ADR-0014 inherits the same question).