0026: The web surface writes configuration from an allowlist, through the CLI's own validation; agents first
- Status: Accepted (Navjyot, 2026-10-06; proposed 2026-09-30); amended by 0038 on credentials
- Date: 2026-09-30
- Related: 0024 (authentication for every write; this ADR adds no layer and removes none), 0005 (the roster is one file per machine), 0025 (the front end that hosts the page)
Contextβ
The stated end goal for orion web is that a person can change configuration
and answer the questions agents stop on from the browser, not only watch
runs. Today the surface writes nothing: every route is registered read-only.
Two things already exist and one does not.
- Authentication exists. ADR 0024 specifies the per-process token, the
Origin / Sec-Fetch-Site check and the Host allowlist, and
internal/localauthenforces them.Handle(as opposed toHandleReadOnly) is the write registration, and a route registered that way is guarded by default. - The approved design exists.
docs/design/web/06-config-panel.htmldecides what a config panel may do: edit limits, collect switches, per-stage rounds and agents, one thing at a time, staged until Save; show where each value came from; show the path a save writes to; refuse values the CLI would only warn about; and mark a list of settings terminal only. - Token delivery does not exist. 0024 says the token is inlined into the page the server serves. Nothing did that, so no page could have authorised a write even if one had been registered.
Validation and persistence live in the CLI. Most of the setters are in
package main and read stdin for their confirmations, so a web handler cannot
call them; two pieces are importable and are the ones this slice uses:
actors.Configure (validates: refuses ci and human, refuses duplicate
names) and config.SaveAgents.
Decisionβ
-
The web surface may write configuration, from an allowlist. Nothing outside the allowlist is writable from the page. Adding a setting to it is a change to this ADR, not a side effect of a handler.
Writable, in the order they are built: agents (one agent's name, model and effort; reset one agent), then limits, collect switches and per-stage rounds.
Terminal only, and not offered by the page at any privilege, exactly as the approved design marks them:
budget.*,vcs.allow_release_branch_merges,delegation.inherit_operator_config,ci.auto_fix,slack.merge_approvers,qa.e2e_base_url,dba.non_prod_dsn, and the bareorion config agents --resetthat wipes the roster for every project. Credentials are never rendered by the page. Amended by 0038: the page may set tracker and Slack credentials write-only, and still never shows them.paths.*,gates.*andvcs.*are not writable either:paths.protectedexists so an agent cannot edit orion.json, and a web path that could would be a way around it. -
The token reaches the page in the HTML the server serves, and nowhere else. The document for the app shell carries it in a
<meta>tag, withCache-Control: no-store(it is per process, so a cached copy would carry a dead one). It is never in an API response body, including the response to a write. The page sends it back inX-Orion-Tokenon every write. -
There is one validator, and it is the CLI's. A write handler calls the same functions the wizard calls, in the wizard's order, and takes the allowed model and effort values from the lists the wizard's menus are built from (
config.AgentModels,config.AgentEfforts). The web package declares no model and no effort of its own, and the page receives the allowed values from the API rather than carrying a copy. -
A write is one agent and one request. The body holds only the fields being changed; unknown fields are refused, so a handler cannot grow into a way to write something the design does not offer; the body is capped. The response is the resulting configuration, exactly what a fresh GET would return.
-
Values the CLI only warns about, the page refuses. Where the CLI asks "are you sure" (concurrency above 10, fix rounds above 5), the page does not offer a confirm button: a browser button that auto-answers the prompt would delete the guard the prompt is. This is the approved design's rule, recorded here so the limits slice inherits it.
-
A save is atomic from the point of view of this process. If the file cannot be written, the in-process registry is put back, so the page never shows a roster no run will see.
-
A save affects runs that start after it. A watcher already running keeps the values it started with. The page says so; it does not imply otherwise.
-
Limits and landing switches, per project (added 2026-10-06). The page writes the circuit breakers (
config.Limitsand the per-stage ones inqa,dba,cianddiscovery) and the twocollectswitches, nothing else inorion.json. The rules are not copied:internal/settingsholds the name tables, the block each field lives in and the in-place patch, and bothorion config limits|collectand the page call it, so they cannot disagree. The CLI keeps its prompts; the page refuses where they would fire (settings.ConfirmAbove: concurrency aboveconfig.ConcurrencyWarnAbove, a per-stage count aboveconfig.FixRoundsWarnAbove) and names the command. The consequence is that a few settings whose ordinary values exceed that point, such asqa.verdict_minutesraised above its default of five, can only be changed from the terminal.A project is chosen from the registry and never named by path, and the file written is the registered working copy, the one the watcher reads (
settings.File), not a sandbox clone. A save patches the text in place so comments and key order survive, and writes atomically.
Alternatives consideredβ
- Validate in TypeScript. Rejected: it is a second copy of rules that change (a new agent, a new effort level), and the copy is what the page would keep enforcing after the CLI moved on.
- One endpoint that replaces the whole roster. Rejected: two edits arriving together lose one of them, and it makes the bare reset the design forbids one malformed request away.
- Deliver the token in a cookie or a query string. Already rejected by 0024 for the reasons given there.
- A separate config service the web and CLI both call. The right end state
if the setters keep multiplying, but it is a refactor of
package mainthat no write path needs yet. Extract when the second slice needs it.
Consequencesβ
- The web surface has a write path. Every write route is registered with
Handle, and the tests assert both that an unauthenticated write is refused and leaves nothing on disk. - Anyone who can load the page can read the token from its source. 0024 accepts that: the token defends against requests an attacker cannot forge, not against a reader already running as the operator.
- Limits are per project (
orion.json), and the web has no project selection (internal/web/confighandler.gosays so). The limits slice needs that decision first. - Answering a blocked ticket needs a Jira writer, and the web package holds no tracker credential by design. That is a credential-boundary decision of its own and gets its own ADR before any code.
- The setters that live in
package mainwill have to move to a package before the limits slice can reuse them.
Status of this decisionβ
Proposed. Accepting it accepts the allowlist and the token-delivery rule; the agents slice and the limits-and-landing slice are implemented alongside it so the rules are exercised rather than described.