ADR-0014 Architecture decision record
Observation is open; run state and control need the Instance token
ADR-0013 minted two tokens and scoped them for delivery — a Sandbox token may deliver to its own agent registrations, an Instance token may deliver to gates. It said nothing about who may read a run or cancel one, and the code took the obvious shortcut: authenticated, meaning any token we minted. That is a hole. It also left the visualizer with no way in at all, which is the same question from the other side.
Building jr2 visualize (the Machine renderer and its live run highlighting) forced both halves at once: the page is a browser, and a browser holds no token. So this ADR splits the HTTP surface into three bands, and adds an observation projection — the page gets a view instead of a credential.
Decision
Three bands, and the middleware is what says which.
open /workflows, /workflows/:name/machine, /viz/* structure
/workflows/:name/runs, …/runs/:runId/events OBSERVATION (new)
authenticated /agents/:iid/* the Agent's surface [Sandbox token]
instanceOnly /runs, /runs/:id, /runs/:id/events (GET + POST) run state + control [Instance token]
POST /workflows/:name/runs starting a run (ADR-0058)
/runs/:id/gates/:gate/events gates (ADR-0011/0013)Run state and run control are the Instance token's alone.
authenticatedaccepts any token we minted, so a Sandbox token — the one credential that sits in a pod an Agent shares a network namespace with — could read every run's context (other features' branches, ticket bodies, review verdicts, Harness endpoints) and CANCEL any run. Neither is on the Agent's surface, no more than a gate is: ADR-0013 scopes that token to delivering into registrations recorded against its own Sandbox. This closes the same class of hole ADR-0013 closed for gates, on the routes it did not name. The band is named for the credential it requires, not for a role — "operator" in this repo is the Kubernetes operator (ADR-0001).The observer gets a PROJECTION, not a credential.
RunObservationis{ runId, workflow, status, value, children }— identity, and where in the Machine the run is. What is absent is the point:contextis the workflow's working data, andinstanceId/faultname live infrastructure and leak error text. A statevalue(and thechildrentree of state values below it) is a tree of state keys — it is structure, and structure is already open at/workflows/:name/machine. So an observer learns nothing here it could not read from the Machine doc, except which states are lit.observe()is the one place that line is drawn, andRunChildhas no context field at any depth — the redaction surface cannot grow by forgetting.Observation is scoped to one workflow, because that is what an observer already knows (it is in the page's path). There is no listing of everything an Orchestrator happens to be running, and the feed serves live runs only — reading a settled run's terminal status through the store stays on
/runs/:id, which is guarded.An
emit's payload does not cross. The observation feed carries an emit'stypealone: an author's emit payload is author data, the same class of thing as context.
Considered options
- Give the page a token. It would have to be the Instance token — gates, cancel, every run's context — handed to whatever can load a URL. Putting it in
jr2.config.ts(checked in) is worse still, and the token is minted per boot, so the page would not have it anyway. Rejected: the visualizer needs to see a run, and seeing is not a subset of the credential that steers it. That mismatch is the whole reason a projection exists. - Keep
/runs*onauthenticatedand filter in the handler. Rejected: the guard would then depend on every future handler remembering to redact. The band is a middleware precisely so the default is refusal. - A fourth "observer" token. Rejected: it buys nothing today (the observation surface carries no secret) and costs a minting, distribution, and revocation story. If observation ever needs to be private — a deployed jr2 Application on a shared network — that is the ADR to write, and it is the same open question ADR-0013 left about authn for non-kube callers.
Consequences
auth.test.tschanged its mind. Its prose already said "Observation and control are the operator's, not the Agent's" directly above assertions that a Sandbox token gets 200 on a run read and 200 on CANCEL. Those are now 403. The test pinned the hole.The API is a cluster Service (ADR-0019), so "open" means open to whoever can reach it — in-cluster peers, a port-forward, or an ingress — deliberately, since what is open is structure plus lit states. Anything that would embarrass a user if seen belongs behind
instanceOnly, and that is where it is.The CLI is unaffected. It holds the Instance token from the in-cluster Secret (ADR-0019) and reads the guarded routes, as before.
Open, inherited from ADR-0013: what a deployed jr2 Application uses for human callers. Whatever it is, the band split survives it — only the credential in the
instanceOnlyband changes.One scoped exception, and it does not live on this surface at all (ADR-0023). The Harness prints each Agent's conversation — prompts, assistant text, thinking, tool calls, and the answer jr2 gave each Menu pick — to container stdout, which leaves the pod for the cluster's log plane and lands in front of a reader this document never gets to check. That is a real exception to the rule above, and it is stated rather than reasoned away. Two things bound it. It is not a new band: the HTTP surface is untouched, nothing here is served, and a reader gets it from
kubectl logs, holding cluster log access instead of a jr2 credential. And a Working tool's results are excluded — the file contents, command output, API responses it returns never print, so the mechanical leak (an Agentcats a config while debugging and its secrets ride a log shipper into 30-day retention) is off the table, leaving what an Agent reasons aloud and what jr2 answered its picks. What an Effector returns in that answer is scrubbed before it signs it as ADR-0068 claims: no secret the Effector holds, in the forms it sent or in common encodings. The scrub does not try to catch a transformed secret (a hash, a truncation, an encoding it does not list), a partial echo, or a side channel, and a credentialed command's child reads the secret its command names, so it can print that secret in such a form; then it prints there as the model read it. The answer also carries an Effect's output as the model read it: acommandEffect's output prints there up to the bound, and a Held value an upstream echoed into that output prints with it (ADR-0023, ADR-0059). What remains, deliberately, is that on a shared cluster the population who can read an Agent's reasoning, and what its picks answered, is whoever can read pod logs. If that becomes unacceptable, the fix is the read-through ADR-0023 declined to build —instanceOnly, on this surface, where the band already works.
Evidence: packages/orchestrator/src/http.ts (the three middlewares), run-host.ts (observe / RunObservation / RunChild), test/auth.test.ts (the boundary), test/http-viz.test.ts (a planted secret never reaches the feed, at any depth of the child tree).