Build DEC-0064 Option D into running code — the v0 agency governance dashboard is a non-prerendered `(operator)` route group that reuses dispose.ts, gated behind DOSSIER_OPERATOR

0068-operator-dashboard-route-group

decision read as Explain confidence verified status active 2026-06-20 owner forward-deployed-engineer
Reversibility
two-way door

DEC-0068 — The v0 agency governance dashboard: a non-prerendered (operator) route group over dispose.ts

Reversibility: two-way door. The change is additive — one route group, one server-only bridge module, one workspace dependency, one vitest $lib alias. The disposition mechanism (dispose.ts) is untouched; this is a second front door onto it, exactly as DEC-0064 framed it. Delete the (operator) group and the platform is unchanged (the CLI front door still disposes). The public deploy is unaffected — it never sets DOSSIER_OPERATOR and nothing prerendered links to the group.

Context — DEC-0064 decided the posture; this builds it

DEC-0064 disposed the topology question: the public /board deploy holds no mutation authority (read-only, prerender=true, sovereignty contract — DEC-0001), and governed approve/reject lives with the operator control plane. It adopted Option D — a local-only operator dashboard ("the app run locally against a local tenant repo, never the Vercel deploy") as the optional browser home, and assigned the Principal Forward Deployed Engineer its wiring: "reuse dispose.ts, no new mechanism." The operator greenlit it as the next move. This DEC records the concrete build of Option D as the v0 agency governance dashboard that makes DEC-0052 Phase 0 real — the human approve / ship / watch surface over the loop (Spec the v0 agency dashboard surface (Phase 0 dogfood — Dossier's own .claude/agents team on Dossier's own OKF; daily-standup / approve-ship loop)).

Decision — the concrete topology

A SvelteKit (operator) route group at /operator, structurally segregated from the read-only public surfaces:

  • (operator)/+layout.ts is prerender = false — the structural opposite of the (app)/board route's prerender = true. The dashboard reads + mutates the live tenant repo at request time; it cannot be a static file. It builds as a server function, never a prerendered page (verified: board.html is still emitted; no operator appears under prerendered/).
  • (operator)/operator/+page.server.ts — the load reuses listReviewQueue; the approve/reject form actions reuse approveTask/rejectTask. These are the EXACT @dossier/runtime functions packages/runtime/src/cli.ts calls (cmdReviewQueue/cmdApprove/cmdReject). The disposition logic is not forked — single source of truth, so the browser and the CLI can never drift and Inv 3 (only this human-invoked path reaches done) is non-bypassable by construction regardless of front door.
  • $lib/server/operator.ts — the server-only bridge holding all @dossier/runtime calls + the gate. SvelteKit never bundles $lib/server/* to the client, so the tenant repo path + git + the mutation seam stay server-side, never in the browser (the DEC-0064 §Context posture).

Three consequential choices

  1. New @dossier/app → @dossier/runtime workspace dependency. The app gains a server-side dependency on the runtime control plane. Confined to $lib/server/* (client-unreachable). This is the first time the serve layer (app) depends on the orchestration layer (runtime) — deliberate, because the dashboard is a control-plane front door.
  2. The DOSSIER_OPERATOR env gate. The mutating actions refuse unless DOSSIER_OPERATOR=1. The public Vercel deploy never sets it, so it can never gain mutation authority — the read-only contract holds literally, by a single switch, not by hoping no one wires a button. The read (review queue) is always allowed (read-only-then-governed). DOSSIER_OPERATOR_ROOT/_CLIENT locate the tenant (the CLI's --root/--client analogue), defaulting to the dogfood RBA tenant.
  3. The proposed-diff lenient join. The drain-emitted proposed_atoms (off the strict TaskSchema, DEC-0053 Inv 2 / DEC-0064) is stripped by the validated listReviewQueue. To surface it without forking the queue authority, loadReviewQueue joins it from a lenient readKbAtoms read by atom id — the SAME read the public board loader uses — keeping the two surfaces in lock-step on the diff while listReviewQueue stays the single authority for which tasks are in review.

Why a route group, not a button on the public board (rejected)

DEC-0064 already rejected Option B (a mutating server action on the public app) outright — it would put a credential-bearing, git-mutating endpoint on the replaceable public cache and tunnel the sovereign repo through Vercel. This DEC honors that: the operator surface is a distinct group with its own prerender=false + env gate, so the public board's one clean property — static prerenderability + zero mutation authority — is preserved untouched.

Proven this session (no fabricated status)

  • Live browser round-trip: Playwright drove /operator against the provisioned RBA tenant — the review queue rendered the real task-data-estate-assessment-northwind with full context; clicking Approve & ship drove the real approveTask → the task transitioned review → done with approved-by:human provenance and a real tenant-git commit (dossier(board): approve … → done — compounded by human (DEC-0053 Inv 3)); the CLI review-queue went 1 → 0; the after-state rendered the empty queue. The tenant was then restored to review (the proof is repeatable; clients/ is gitignored so the main repo is untouched). Screenshots under screenshots/operator-0{1,2,3}-*.png.
  • Offline integration test (packages/app/test/operator-dashboard.test.ts, 7 cases): load reads the live queue + the lenient proposed diff; approve drives review→done+commit and clears the queue; reject drives review→backlog with a recorded reason; the gate refuses a non-review task (409 — the CLI exit-2 analogue) and a missing id (400); and with DOSSIER_OPERATOR unset the read still works but mutation is refused (403) — the public-deploy posture.
  • Gates: pnpm typecheck clean · pnpm --filter @dossier/app check 0 errors · pnpm test 520 passed / 2 skipped · pnpm build clean (board.html still prerendered, operator is a server function) · pnpm kb:check clean · pnpm plugin:check in sync.

Two bugs found + fixed while proving it (recorded for the next person)

  • Client bundle pulled node:path. The page component imported statusLabel from the @dossier/okf-view barrel, which drags in routes.js (uses node:path) — fine at SSR, but it threw on client hydration after invalidateAll() (a 500 on the post-disposition reload). Fix: the component imports NO Node-touching module; the gate label is a client-side constant, and any status-label need comes server-side via the load. The integration test guards this class going forward.
  • proposed_atoms was empty because listReviewQueue's validated parse strips off-schema fields — hence the lenient-join choice above.

Consequences