zynk 1.1.0

Portable protocol and helper CLI for multi-agent collaboration.
# 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.