Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/adr/0027-dashboard-root-only-shell-client-strip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 0027. Dashboard: the server emits a ROOT-ONLY shell; the status strip + nav render in the client

- Status: Accepted
- Date: 2026-07-12
- Supersedes (in part): [0025](0025-dashboard-v4-preact-client-render.md) — its decision that the
persistent **status strip** and the **tab nav** stay SERVER-RENDERED for a calm-when-blind first
paint. Everything else in 0025 (Preact reconciler, view_model/props as the serde JSON contract,
built-from-source bundle, zero-egress, server-derived honesty tokens) stands.
- Reaffirms: [0016](0016-severity-vs-urgency.md) — presentation is a view, never a gate; and the
honesty axes of [0019](0019-dashboard-v3-presentation-architecture.md)/0025 (blind ≠ green).

## Context

Under ADR-0025 the engine went Preact-only for every view *body*, but kept TWO parts server-rendered
in maud — the status strip and the tab nav — so the honest calm-when-blind banner would paint before
any JS ran. That split had two concrete costs that surfaced in production (JEF-408):

1. **A dead recurring poll masqueraded as a working one.** `poll.js` called its injected interval as
`(ms, fn)`, but the default was native `setInterval` (`(fn, ms)`), so `setInterval(POLL_MS, tick)`
handed the *number* 5000 as the handler. The recurring poll never fired — only the initial tick —
so a client tab-swap blanked the view forever ("clicking does nothing"). Worse, the browser
coerced the numeric handler to the string `"5000"` and took the legacy string-handler path, which
compiles the string as code (an eval); the strict CSP (`script-src 'self'`, no `unsafe-eval`)
correctly blocked it. One reversed-args bug, three symptoms.

2. **Two rendering stacks for the same honesty derivation.** The maud `status_strip.rs` and the
(client) view bodies both had to agree on the judging axis, kept in sync by hand. Any maud-only
strip meant the strip and the body could drift, and the shell carried body HTML the client would
have to reconcile around.

The calm-when-blind justification for a server-rendered strip is weaker than it first appears: a
blank document before the first fetch is *honest* (absent is not a green all-clear), and the strip's
honesty is a **derivation**, not a render — as long as that derivation stays server-side, WHERE the
strip DOM is produced is immaterial to the honesty contract.

## Decision

We will move ALL body HTML — the status strip and the tab nav included — to the Preact client, and
have the server emit a **ROOT-ONLY** document shell.

- **Server shell (`page.rs`):** the body is just `<div id="dash-root" data-tab=…>` + the deferred
bundle `<script>`. The `<head>` keeps the cluster-labelled `<title>` + css link (head metadata is
not body HTML). The retired maud `components/status_strip.rs` + `components/nav.rs` (and the whole
`dashboard/components/` module) are deleted.
- **Client strip (`strip.jsx`):** a faithful port of `status_strip.rs`, reusing the existing
`dashboard.css` classes verbatim (no new palette). The judging axis is chosen by a single
SERVER-DERIVED token, `judging-state` ∈ {`all-clear`, `watching`, `judging`, `warming`, `no-model`,
`blind`}, added to the strip props' hand-written `Serialize` alongside `all-clear`/`watching` and
computed from the SAME branch logic. The client only SWITCHES on the token — it performs zero
honesty derivation (the ADR-0025 contract).
- **The poll bug:** the default interval becomes `(ms, fn) => setInterval(fn, ms)` — correct arg
order — which fixes the dead poll, the blank tab-swaps, AND the CSP eval violation at once (there
is no longer a string handler to eval). A client tab-swap also restarts the poll so the new tab
refetches immediately. **The CSP stays strict** (`script-src 'self'`, no `unsafe-eval`); the bug was
our reversed args, not the policy.

## Consequences

- **Honesty is preserved.** Green renders ONLY when `judging-state === "all-clear"`; watching /
warming / no-model / blind and any awaiting/uncertain counts are non-green. A blank before the
first fetch is honest (absent ≠ green — the connection banner already says "connecting…"), and the
honesty tokens stay server-derived, so the client can never invent a green.
- **One rendering stack, no drift.** The strip and the view bodies are now the same Preact tree over
the same serde JSON; the maud/Preact sync burden and the `components/` boundary guards are gone.
- **Deferred: SSR/hydration of the strip.** The pre-first-fetch blank is honest but not instant; a
follow-up may server-render the strip's initial HTML (from the same tokens) and hydrate it to close
the gap. Noted, not done here — the honesty contract does not require it.
- **Downside accepted:** first meaningful paint of the strip now waits for the bundle + first fetch
(previously the strip painted with the document). This is an acceptable latency trade for an
in-cluster, authenticated, low-latency operator dashboard, and the blank it shows is honest.
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ Copy [`0000-template.md`](0000-template.md) to start one.
| [0022](0022-quarantine-the-entry-is-the-default-containment.md) | Quarantine the internet-facing entry is the default containment (entry-only, additive/reversible default-deny); the surgical edge-cut is the refinement used only when it suffices | Accepted |
| [0023](0023-delta-aware-adjudication.md) | Delta-aware adjudication: the full cluster state is the context, the change is the question | Accepted |
| [0024](0024-no-redundant-by-construction-predicates.md) | Corroboration shapes must be load-bearing when merged, not deferred-dead — a predicate whose result is already fixed by an existing arm lands when it bites, not ahead of it | Accepted |
| [0025](0025-dashboard-v4-preact-client-render.md) | Dashboard v4: a bundled Preact client reconciling from same-origin read-only JSON — supersedes 0019's maud server-render *mechanism* (its IA + honesty axes survive); view_model/props retained as the serde JSON contract, bundle built-from-source + gitignored, honesty stays server-derived | Accepted |
| [0025](0025-dashboard-v4-preact-client-render.md) | Dashboard v4: a bundled Preact client reconciling from same-origin read-only JSON — supersedes 0019's maud server-render *mechanism* (its IA + honesty axes survive); view_model/props retained as the serde JSON contract, bundle built-from-source + gitignored, honesty stays server-derived | Accepted (its server-rendered strip/nav superseded in part by 0027 — the body is now root-only) |
| [0026](0026-adjudication-judge-qwen3-1.7b.md) | Promote qwen3:1.7b as the adjudication judge (bakeoff: 12/12, the only clean sweep of all three evidence types + every refute; deployed qwen2.5:3b-instruct is 11/12, misses exposed-secret-in-field) — pending Pi latency/RAM validation, strict-JSON on-Pi, the delta-aware prompt path, and in-cluster zero-egress availability | Proposed |
| [0027](0027-dashboard-root-only-shell-client-strip.md) | Dashboard: the server emits a ROOT-ONLY shell (`<head>` + `#dash-root`); the status strip + tab nav move to the Preact client — supersedes 0025's server-rendered strip/nav. Honesty preserved (blank ≠ green; the all-clear/watching/`judging-state` tokens stay server-derived). Also fixes the reversed-args `setInterval` bug (dead poll + blank tab-swaps + CSP eval violation) with the CSP kept strict; SSR/hydration deferred | Accepted |

See also [`../VISION.md`](../VISION.md) for the longer-form narrative this ADR realizes.
10 changes: 5 additions & 5 deletions engine/examples/dashboard_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -977,12 +977,12 @@ fn preview_admission(state: &DashboardState) -> view_model::props::AdmissionView
view_model::build_admission_view(preview_strip(state), &state.policy_log.snapshot())
}

/// Render the server-rendered SHELL for a tab through the dashboard's PUBLIC render path (ADR-0025 /
/// JEF-398): head + the persistent status strip + the 5-tab nav + the Preact `#dash-root` mount. The
/// view BODY is client-rendered from the `/api/{tab}.json` snapshot (served below), so this preview
/// exercises the SAME Preact-only path production serves.
/// Render the ROOT-ONLY document shell for a tab through the dashboard's PUBLIC render path (JEF-408,
/// superseding ADR-0025's server-rendered strip/nav): the `<head>` + the Preact `#dash-root` mount.
/// ALL body HTML — the status strip, the tab nav, and the view body — is client-rendered from the
/// `/api/{tab}.json` snapshot (served below), so this preview exercises the SAME path production serves.
fn render_page(state: &DashboardState, tab: Tab) -> String {
page::page(&preview_strip(state), tab).into_string()
page::page(&state.cluster, tab).into_string()
}

/// Serialize a scenario's per-view props as the `/api/{tab}.json` snapshot the Preact client
Expand Down
17 changes: 0 additions & 17 deletions engine/src/engine/dashboard/components/mod.rs

This file was deleted.

35 changes: 0 additions & 35 deletions engine/src/engine/dashboard/components/nav.rs

This file was deleted.

183 changes: 0 additions & 183 deletions engine/src/engine/dashboard/components/status_strip.rs

This file was deleted.

Loading
Loading