ADR 0006 — Policy editing under the read-only sidecar invariant
- Status: Accepted
- Date: 2026-06-03
Context
The post-launch dashboard adds an editable Policy surface (/policy) — the
design handoff (app/policy.jsx) shows a per-column control, a live
schemabrain.yaml panel, a staged −/+ diff with an Apply button, and a
Re-sync from store button on a drift banner. Taken literally, both buttons
write: Apply mutates the enforcement policy, Re-sync reconciles the file to the
store.
That collides with a hard invariant the dashboard’s read-only posture depends on
and that tests/dashboard/test_invariants.py pins:
- The sidecar is read-only.
assert_route_table_is_read_only(schemabrain/dashboard/sidecar.py) rejects any route whose methods includePOST/PUT/PATCH/DELETE; every/api/*route isGET/HEAD/OPTIONSonly. The sidecar binds127.0.0.1and serves bytes; it never holds a writable handle to the operator’s policy. - The policy file is the operator’s, edited in their editor + VCS. The
canonical enforcement policy lives in
pii_policy.yamland is applied to the store by the operator runningschemabrain policy apply.schemabrain serveresolves the policy once at boot; an out-of-band edit is exactly the drift the banner reports (ADR references the_compute_policy_driftmtime sentinel).
policy apply, so the YAML in VCS and the store
could diverge with no audit trail; (3) require the sidecar to hold write
credentials to the store it currently only reads.
This is a one-way door for the whole editor surface — it decides what “Apply”
is — so it is locked here before the component is built.
Decision
1. The sidecar stays read-only. No write route is added for policy.
The route-table invariant is non-negotiable. The Policy surface adds exactly one new route, and it is a pure, side-effect-free GET:GET /api/pii/policy/preview— takes the operator’s staged block set + column overrides as query parameters, constructs an in-memoryPolicy, and returns the canonicalpolicy_to_yaml(...)rendering plus the line-level diff against the currently-active policy. It reads nothing it mutates and writes nothing. It is the server-side renderer the staged YAML panel and diff consume (see ADR 0007 for why rendering is server-side).
assert_route_table_is_read_only continues to pass; the existing
test_invariants.py assertion is extended to cover the new route.
2. “Apply” means copy-the-YAML + reveal-the-command, not write.
The Apply affordance does not persist anything. When the operator clicks Apply on a non-empty staged diff, the dashboard:- Copies the canonical YAML (the full server-rendered
pii_policy.yamlbody for the staged policy) to the clipboard. - Reveals the exact CLI command to make it real —
schemabrain policy apply pii_policy.yaml— alongside a one-line reminder that the running firewall picks the change up on the nextschemabrain serve(re)start.
pii_policy.yaml (under VCS), runs the
command, and restarts serve. The dashboard is a policy authoring aid, not a
policy mutation surface: it does the tedious part (deriving correct canonical
YAML + showing the diff) and hands the durable, auditable action back to the
operator’s own toolchain.
Staging remains pure client state: toggling a category or a column override
mutates a pending object, never the applied baseline. Discard clears
pending. Apply does not clear pending either — the staged state stays visible so
the operator can copy again after pasting; it clears only on a real refetch when
the underlying policy changes (i.e. after they actually ran the command and the
GET reflects it).
3. “Re-sync from store” is also copy-a-command, not a write.
The drift banner’s reconciliation affordance follows the same rule. Drift means the on-disk YAML changed after serve booted; the honest resolution is “restartschemabrain serve” (or re-run policy apply then restart). The banner reveals
that command; it never reaches back and writes the file or the store.
4. Honest copy throughout — no button implies a capability we don’t have.
Every actionable control on the surface is labelled for what it actually does: “Apply” reads “copy YAML + command”, the command is shown verbatim, and the restart caveat is explicit. No control is styled as a live mutation that silently no-ops or writes through a hidden path. This matches the launch’s no-fabricated-capability rule and the existing read-only PolicyView’s footnote, which already tells operators to editpii_policy.yaml and run
schemabrain policy apply.
Consequences
What becomes possible:- An editor-grade Policy surface — stage, preview canonical YAML, see the diff, copy-to-apply — with zero change to the sidecar’s read-only, localhost, no-Node posture.
- The
pii_policy.yamlfile stays the single source of truth, edited through the operator’s editor + VCS +policy apply, so every policy change keeps its existing review/audit trail. The dashboard never becomes a second, unaudited write path. - The route-table invariant test keeps the whole
/api/*surface GET-only as a hard merge gate.
- No one-click apply from the dashboard while the read-only invariant holds. If a future hosted/authenticated transport is ever added (explicitly out of scope today, see the post-launch remote-MCP note), one-click apply could be revisited behind that auth boundary — never on the localhost byte-server.
- The surface is only as fresh as the GET it reads: after the operator runs
policy apply+ restarts serve, the dashboard reflects the new policy on its next refetch. There is no push; this is acceptable for an authoring aid.
- A
POST-based apply behind authentication — tied to any future hosted transport, not to this PR. - Writing the YAML file directly from a local-only helper (e.g. a separate privileged CLI bridge) — not pursued; the copy-and-run flow keeps one obvious apply path.
References
schemabrain/dashboard/sidecar.py—assert_route_table_is_read_only, the existingGET /api/pii/policyroute, and the newGET /api/pii/policy/preview.tests/dashboard/test_invariants.py— the GET-only route-table merge gate.schemabrain/pii/policy_yaml.py—policy_to_yaml/parse_policy_yaml, the canonical YAML round-trip the preview route renders with.schemabrain/cli.py—schemabrain policy apply/policy show, the operator-side apply path the dashboard hands off to.web/components/policy/PolicyEditor.tsx— the editor whose Apply this governs; it supersedes the prior read-only policy view, which already documented the edit-YAML-then-policy applyworkflow in its footnote.- ADR 0005 — dashboard routing under static export (the read-only, localhost, no-Node serving contract this preserves).
- ADR 0007 — Policy editor control model (the levers whose Apply this governs).