ADR-0032 Architecture decision record
The Console unlocks with the Instance token, and the bands do not move
ADR-0014 built the three-band HTTP surface on one premise: the visualizer page is a browser, and a browser holds no token — so it got a projection instead of a credential, and "give the page a token" was explicitly rejected. That page is now growing into the Console: it starts runs and answers Gates, both of which are writes on the guarded surface. This ADR records how a browser earns those writes without moving a single band — and it can stay this small because of a fact ADR-0014 did not lean on: the Instance token is already one kubectl get secret away for exactly the audience the Console serves. There is no new distribution problem, only a new presentation surface.
Decision
- The human presents the token; the page never ships with one. The Console's nav carries a password field. Empty, the page is precisely ADR-0014's observer: open structure and observation, no control rendered. Filled, the page sends
Authorization: Beareron the guarded surface — which unlocks both directions at once: the writes (POST /workflows/:name/runs,POST /runs/:id/gates/:gate/events) and theinstanceOnlyreads the control UI needs anyway (GET /runs, andGET /runs/:id— whereGateViewlives). The page as served stays open and inert; ADR-0014's rejection stands, because what it rejected was baking the credential into the page. - The token lives in
sessionStorage— survives a reload, dies with the tab, never a cookie (no ambient credential, no CSRF surface). It is validated on entry with a cheap guarded read so the field shows live/invalid immediately; any later 401 drops the UI back to observer mode rather than failing per-widget. - Gate discovery is a frame-triggered guarded read, not an open-band extension. A Gate exists exactly while its invoking state is entered (ADR-0011), and every entry/exit lands a status frame on the open workflow feed (ADR-0022) — so on each frame, a token-holding Console re-fetches
GET /runs/:idfor the affected run and foldsgatesinto its store. Perfectly synchronized, no polling loop, and the observation projection is untouched. A delivery's success is likewise confirmed by the next frame emptying the card, never by local bookkeeping. - Page addresses content-negotiate; JSON is the default dialect.
/and/workflows/:nameserve the Console shell toAccept: text/htmland JSON to everything else — so the workflow-detail resource keeps its natural address (the run-input schema's home, ADR-0033) and no future page has to squat on a mangled sibling path. Nothing existing moves: EventSource askstext/event-stream, and the Harness's Menu and the CLI never ask for HTML./viz/*is deleted (greenfield, no redirects); assets move to/assets/*, which also retires the "a workflow namedassetsis shadowed" hazard.
Considered options
jr2 ui— the CLI opens the browser through an authenticated local proxy. A convenience layer worth building on top of the token field later, not a substitute: it has no answer for the deployed jr2 Application behind an ingress, where the browser talks to the Service directly and the CLI is not in the path.- A fourth, scoped credential ("may answer gates, may not cancel or read context"). Still rejected, as in ADR-0014 — but the calculus has narrowed: it becomes worth writing only when gate-answering is delegated to people who must not hold cluster RBAC. That is a real future (an approval inbox for non-operators); it is not this one.
- Announce gates on the open observation feed (even just
{runId, gate}). Rejected: it widens the projection for a convenience the token already solves, and the open band has an unresolved exposure question as it stands (spawn ids — the ADR-0014 open call). The projection stays the one place the line is drawn. - A
/uipath prefix, or moving the API under/api. Rejected for negotiation: the first is ceremony on the address a human types, the second churns the Harness's Menu, the Custodian's route allowlist, the CLI, and the ADR-0009/0013 route tables for cosmetics.
Consequences
- Zero new server surface except negotiation. No new token, no new band, no new route class;
auth.test.ts's boundary assertions are untouched. The server-side diff is: negotiate two addresses, serve the shell, retire/viz/*. - Possession remains cluster RBAC. Whoever can read the instance Secret could already do everything the Console unlocks; the field changes where the token is typed, not who holds it.
- The XSS caveat is the real one and is bounded by what the page is: a token in
sessionStorageis readable by scripts on the page, so the Console must keep serving only its own assets — no CDN, no third-party script, exactly the constraint the viz page already lives under. - GLOSSARY.md: Console becomes a glossary term (visualizer, dashboard, UI, viz on its Avoid list).
- The Console's UX gets a browser tier —
@console-tagged Cucumber scenarios holding a Playwright page, in@jr2/e2e, excluded from the default profile (ADR-0010 as amended). What the page believes stays unit-tested inconsole-store.test.ts; the browser tier asserts what a user does and sees.