# ADR 031: Browser-Originated Dashboard Writes — Authorization + Audit
**Status:** accepted
**Date:** 2026-05-30
**Participants:** Claude (kickoff `adr031kick`, brainstorm
`docs/superpowers/specs/2026-05-30-adr-031-browser-write-auth-brainstorm.md`,
draft `adr031b1`, R2 revision `adr031b2`), Codex (counter `adr031c1` + 6
guardrails, herdr-socket addendum `adr031herdr1`; review R1 request-changes
`docs/reviews/031-r1-codex.md`, R2 approve `adr031apv1`
`docs/reviews/031-r2-codex.md`).
**Operator gate:** Zevs — accepted 2026-05-30 (composer-send first slice;
shell-out-to-`current_exe` write path; loopback + Host/Origin + per-serve
CSRF-header auth; raw-socket / reveal / gate-conflict / remote deferred).
Design-only. Defines how the operator may ACT from the dashboard (the writes ADR
025/030 deferred) without breaking ADR 024/025/027/029. No implementation until
accepted; the v0.7 slice is a separate cycle.
## Context
ADR 025 deferred browser-originated writes to "a later ADR [that] defines command
authorization and audit behavior" (`025:158-159`), kept the CLI as the only
mutation path (`025:161-162`), and used loopback + OS-user trust with no token
while read-only (`025:244-245`). ADR 030 named that **ADR 031** and reserved the
candidate writes (composer send, gate/conflict decisions, reveal, mode switch,
browser export). The v0.6 dashboard is read-only.
## Decision
### D1. First write slice = the operator composer send (one action)
ADR 031's first write is the **operator-originated audited message** (the
mockup's composer). Narrowly scoped:
- an **existing** selected session (no browser-originated session creation);
- a herdr target chosen from **known agent/address rows** (not free-typed);
- `source = operator:dashboard` (or `operator:dashboard-<serve-id>`);
- `command_origin = operator`, `verified_by = helper-tool`,
`delivery_status = sent` on transport success.
**Reveal, gate/conflict decisions, and mode switch are deferred.** Reveal is NOT
the smaller first slice it appears: hash-only payloads are intentionally never
stored (ADR 029 + the v0.6 R1 fix NULLs `messages.payload_excerpt`, and the audit
file omits the body), so a real reveal needs a separate **payload-custody** model
— its own future ADR. Gate/conflict writes are blocked until those event kinds
have producers (ADR 030 deferred them).
### D2. Write path = the dashboard server shells out to the `zynk` CLI
The dashboard is a thin **authorized trigger**, not a writer. On an accepted,
authorized write it spawns the **same `zynk` binary** and reuses the audited path:
- invoke **`current_exe()`** (the running zynk), never a PATH-resolved `zynk` (no
hijack; version-matched);
- pass an **argv array** via `Command::new(...).arg(...)`, **never a shell
string** (no shell, no injection);
- build **typed argv from validated form fields** — the browser never supplies
arbitrary CLI args/flags;
- do **not** hold a DB connection while the child runs (the child owns the write);
- **capture stdout/stderr** and surface validation/transport/integrity-gap output
(and the reprinted recovery command) so a gap is recoverable — but **HTML-escaped
/ rendered as text, never as HTML**. That output can carry user-controlled
content (e.g. the message body in the integrity-gap recovery,
`send_herdr.rs:266-270`), so it is escaped like all read-model output (the XSS
residual, D4) — "verbatim" means the full text, escaped, not raw HTML.
This keeps ADR 025's single audited write path and inherits ADR 029 (audited send,
integrity gap) + ADR 024 (honest `sent`) unchanged. (Rejected: a direct in-process
DB write — it duplicates the audited-send/validate logic, risks divergence, and
breaks "the CLI is the only mutation path.")
### D3. Serve targeting (ADR 027 orthogonality)
Browser writes pass `--db` equal to the **DB being served** and `--root` equal to
the **serve artifact root**. `--root` is not inferred from `--db` (ADR 027); so
`db serve --root` becomes relevant for writes, not only `--auto-import`.
### D4. Authorization for loopback writes
Reads got away with OS-user trust + no token; writes add a real risk reads lack —
**CSRF** (a malicious local page can POST to `127.0.0.1:<port>`; loopback does not
stop same-machine cross-origin requests, and DNS-rebind can spoof naive host
checks). The first slice requires, on every write POST:
- **loopback-only** binding (unchanged);
- **`Host` exactly** the served authority (`127.0.0.1:<port>`) — anti-DNS-rebind;
- **`Origin` exactly** `http://127.0.0.1:<port>`;
- a **high-entropy per-serve CSRF token** minted at `db serve` start, embedded in
the rendered page, required in an **`X-Zynk-CSRF` request header** (prefer a
header; **never** put the token in the URL);
- **reject `OPTIONS`** and emit **no CORS allow headers** (so a cross-origin POST
cannot preflight a custom header);
- `Sec-Fetch-Site` may be checked as defense-in-depth, not required.
Together these block simple-form CSRF, custom-header CSRF, and DNS-rebind `Host`
tricks. **XSS remains the residual risk** (it could steal the token), so the
read-model's HTML escaping stays load-bearing — including any **child stdout/
stderr, error, or recovery command** the write path surfaces (D2), which is
escaped / text-only and never rendered as HTML. **Remote/shared-host writes stay
deferred** — they need the bearer-token/stronger auth ADR 025 reserved
(`025:255-258`).
### D5. ADR 024 mapping (no schema change)
The enums already cover this (`command_origin IN (… operator …)`, `verified_by IN
(… operator, helper-tool)`):
- **Browser send** → `command_origin = operator`, `verified_by = helper-tool`
(the server/CLI dispatched the transport — ADR-024-honest, never `agent`).
- **Future browser decisions** (reveal/gate/conflict) → `verified_by = operator`,
which ADR 031 defines as an **authenticated local operator POST carrying the
CSRF token**, not mere UI text. Deferred with their producers.
### D6. POST semantics
POST-only writes, then **Post/Redirect/Get** (redirect to a GET render) to avoid
refresh double-submit. Validation failure happens **before** the child spawns; a
transport failure writes **no `sent`** audit (ADR 024); an audit integrity gap
returns a **loud nonzero** UI state plus the recovery command `zynk` printed
(ADR 029). GET performs **no browser-originated write or action** (no composer
send, no operator audit). The pre-existing opt-in `--auto-import` sync on the GET
render path (ADR 025 / v0.2.2 — a CLI-configured `import_outputs_root`,
`db_dashboard.rs:110-127`) is a file→DB import, not a browser action, and is
unchanged.
### D7. Deferred (named, not built here)
- **Raw herdr socket client** in the dashboard — the future path for LIVE features
(`events.subscribe`/`events.wait`, pane/agent list/read,
`pane.report_metadata`/`report_agent`, and possibly a reusable `zynk` transport
layer after `send_herdr` is factored out). Per the herdr socket-API guidance,
automation starts with the CLI wrapper; raw socket is for direct/long-lived
clients. It is **transport/live-integration work, not a reason to bypass the
audited CLI write path** now.
- **Reveal** (needs a payload-custody model), **gate/conflict writes** (need event
producers), **operator mode switch**, **browser export**, **remote/shared-host
writes**.
## Invariants preserved
- **024:** browser send is `sent`/`helper-tool` (server-dispatched), never agent;
transport failure writes no `sent`.
- **025:** the CLI stays the only mutation path (the server shells out to it);
loopback-only; remote deferred.
- **027:** `--db`/`--root` passed explicitly and orthogonally; the DB stays the
live-query canonical, the file the conflict authority.
- **029:** the send is the audited send; the integrity gap applies unchanged.
- **030:** the read-model/feed stays the read path; writes are a separate,
authorized POST surface.
## v0.7 acceptance-test intent (implementation, not this ADR)
- A composer POST with a valid `X-Zynk-CSRF` token, exact `Host`/`Origin`, on an
existing session, sends via `current_exe` `zynk send herdr --session-id …` and
records an audited message (`command_origin=operator`, `verified_by=helper-tool`,
`sent`).
- A POST with a missing/wrong token, wrong `Origin`, or wrong `Host` is **rejected**
(no send, no audit). `OPTIONS` is rejected; no CORS headers are emitted.
- `GET` never dispatches a composer send or records an operator audit (the
browser-action read-only invariant; the opt-in `--auto-import` import is out of
this invariant and unchanged).
- A composer message body containing HTML/script renders **escaped** in the feed
AND in any surfaced child output / recovery command — shown, never executed
(hostile-output XSS guard).
- A transport failure surfaces in the UI and writes **no** `sent`; an integrity gap
surfaces the printed recovery command.
- The argv is built from typed fields only; no form field can inject an extra flag.
## Alternatives considered / rejected
- **Direct in-process DB writes** — breaks the single audited write path.
- **Raw herdr socket from the dashboard now** — premature; CLI-wrapper-first.
- **Reveal-first** — blocked on a payload-custody model.
- **Gate/conflict-first** — blocked on event producers (ADR 030).
- **Remote/shared-host writes** — need the deferred bearer-token auth.
## Related
- `decisions/024-delivery-proof-boundary.md`, `025-db-backed-dashboard-ui.md`,
`027-db-canonical-write-path.md`, `029-audited-send-default.md`,
`030-dashboard-event-model.md`
- Brainstorm: `docs/superpowers/specs/2026-05-30-adr-031-browser-write-auth-brainstorm.md`;
Codex counter `adr031c1` + addendum `adr031herdr1`.
- Future: payload-custody ADR (reveal); event-producer ADRs (gate/conflict);
remote-auth ADR.