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
84 changes: 84 additions & 0 deletions docs/adr/0028-dashboard-client-local-state-simplification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 0028. Dashboard client: local state by default — plain useState, no store, zero runtime deps

- Status: Accepted
- Date: 2026-07-12
- Extends: [0025](0025-dashboard-v4-preact-client-render.md) and
[0027](0027-dashboard-root-only-shell-client-strip.md) — both stand in full. This ADR refines only
the client's INTERNAL state architecture; nothing about the Preact reconciler, the
view_model/props serde JSON contract, the built-from-source bundle, zero-egress, or the
server-derived honesty tokens changes.
- 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/0027 (blind ≠ green).

## Context

The v4 client (ADR-0025/0027) shipped with a hand-rolled observable **store** (`store.js`) holding
the whole client state — active tab, last-good snapshot, connection status, AND every expanded
finding row / open disclosure, the last two mirrored to `sessionStorage` so they survived a soft
reload. Two mechanisms grew on top of it: a keyed-reconcile **tombstone** (`reconcile.js`) that held
a gone-while-open finding on screen for one render as a calm farewell row, and a `purge` step to trim
the persisted id sets after a tombstone cleared.

That is more machinery than the job needs. Preact already keys rows on `finding.id` and diffs them;
a native `<details>` already owns its own open state; a component's own expansion is exactly what
`useState` is for. The store centralised state that wants to be **local**, the sessionStorage
persistence imposed a constraint (open rows survive a reload) the product never actually required,
and the tombstone re-implemented, by hand, a lifecycle the framework's keyed diff performs for free.
Meanwhile the client carried two npm `dependencies` (`preact`, `esbuild-wasm`) as if they were
runtime deps, when the running engine `include_str!`s a compiled bundle and installs nothing.

## Decision

Simplify the client to **local state by default**, plain `useState`/`useEffect` only — no reducer,
no Context, no signals, no new dependency.

- **`App` owns the only shared state**, each field a plain `useState`: `activeTab`, `data` (the
last-good snapshot), `strip` (global posture — its OWN state, decoupled from `data`), `status`
(`first-load` | `live` | `stale`), and `lastGoodAt`. The store (`store.js`) is deleted. The status
transitions are small updaters: a snapshot goes live + resets the freshness clock + persists the
strip (keeping the last if a snapshot omits it — JEF-410); stale never fires before the first
snapshot; a tab swap nulls `data` but never touches `strip`.
- **The poll is decoupled to callbacks** (`poll.js` takes `{ tab, onSnapshot, onStale, liveRegion,
… }`), so it feeds `App`'s `useState` updaters directly with no store dependency. **The JEF-408
fix is retained verbatim**: the default interval is `(ms, fn) => setInterval(fn, ms)` (a
function-first handler, never a number coerced to a string and eval'd), the synchronous first
`tick()`, the stale-on-failure paths, and the mid-selection defer guard all stand. The `App` poll
effect keys on `[activeTab]` so a swap restarts it — an immediate refetch.
- **Expansion / disclosure is LOCAL and ephemeral.** A finding row's expansion is a `useState` in
the row; every "show model prompt" / node-breakdown / judgement disclosure is a **native,
uncontrolled `<details>`**; the Admission signing-row expander is a `useState` per row. None is
persisted. Preact's keyed diff keeps an open row open across a poll for free; the sessionStorage
persistence (open rows survive a reload) is **dropped** — an unnecessary constraint.
- **Keyed removal replaces the tombstone.** A finding that vanishes from a snapshot is removed by
Preact's keyed diff (`key={f.id}`). `reconcile.js` and the client tombstone are deleted, with **no
client replacement** — a future "recently cleared" cue must be **server-shipped** (the honest
cleared-count already carries the signal).
- **Zero runtime npm deps.** `preact` and `esbuild-wasm` move to `devDependencies` (both are
build/test-only — the bundle is compiled and `include_str!`'d, the running engine installs
nothing). The unused `@vitest/mocker` (transitive) and top-level `vite` pin are dropped;
`jsdom` + `@testing-library/preact` + `vitest` stay. The lockfile is regenerated so `npm ci` and
the supply-chain guard match.

## Consequences

Easier:

- One obvious place for shared state (`App`) and the framework's own defaults everywhere else — less
bespoke machinery to read, test, or get wrong. `store.js` + `reconcile.js` (and their tests) are
gone; the views are pure `view`-only renders.
- The honesty contract is untouched and re-pinned: `StatusStrip`/`FindingsEmpty` still render SERVER
tokens (green iff `strip["all-clear"]`), the strip is server-derived, and `strip.test.jsx` +
`honesty.test.jsx` pass unchanged. The client still derives no honesty.
- The dependency surface is honest: the running engine has zero runtime npm deps; the build/test
deps are clearly marked as such.

Harder / accepted:

- **Open rows don't survive a reload.** With the sessionStorage persistence dropped, a soft reload
collapses expanded rows and disclosures. Accepted: expansion is an ephemeral read-affordance, not
state worth persisting, and a reload is a deliberate act; the honesty-critical strip is unaffected.
- **A cleared finding disappears without a farewell.** Removing the tombstone means a gone finding
simply drops from the table with no on-screen "this cleared" cue. Accepted as HONEST — the row is
no longer a live proven path, and the strip's cleared-count carries the fact. A gentler
"recently cleared" affordance, if wanted, is a server-shipped concern (the client derives nothing),
not a client-held tombstone.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,6 @@ Copy [`0000-template.md`](0000-template.md) to start one.
| [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 |
| [0028](0028-dashboard-client-local-state-simplification.md) | Dashboard client: local state by default — `App` holds the 5 shared fields (+ the callback-decoupled poll) as plain `useState`, the hand-rolled store + reconcile tombstone are deleted, expansion/disclosure is local & ephemeral (native `<details>`; sessionStorage persistence dropped), keyed removal replaces the tombstone (a future cleared-cue is server-shipped), and the npm deps prune to build+test only (zero runtime). Extends 0025/0027 (both stand); the JEF-408 poll/CSP fix + JEF-410 strip persistence + server-derived honesty are retained | Accepted |

See also [`../VISION.md`](../VISION.md) for the longer-form narrative this ADR realizes.
10 changes: 4 additions & 6 deletions engine/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 2 additions & 6 deletions engine/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,11 @@
"build": "node build.mjs",
"test": "vitest run"
},
"dependencies": {
"esbuild-wasm": "0.25.10",
"preact": "10.29.7"
},
"devDependencies": {
"@testing-library/preact": "^3.2.4",
"@vitest/mocker": "^3.2.6",
"esbuild-wasm": "0.25.10",
"jsdom": "^25.0.1",
"vite": "^6.4.3",
"preact": "10.29.7",
"vitest": "^3.2.6"
}
}
29 changes: 10 additions & 19 deletions engine/web/src/action/judgement.jsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,22 @@
// One judgement in the Action tab's judgement-audit section (ADR-0025 / JEF-400) — a 1:1 Preact
// port of maud `judgement_entry`: a native `<details>` disclosure whose summary is the entry +
// objectives, opening to the verbatim prompt and reply. Honest when the prompt (the pre-filter
// One judgement in the Action tab's judgement-audit section (ADR-0025 / JEF-400 / JEF-411) — a 1:1
// Preact port of maud `judgement_entry`: a native `<details>` disclosure whose summary is the entry
// + objectives, opening to the verbatim prompt and reply. Honest when the prompt (the pre-filter
// decided) or reply (the model timed out) is absent.
//
// The disclosure's open state is KEYED COMPONENT STATE (persisted in the store under
// `action:judgement-<i>`) so an operator reading a long prompt keeps it open across a poll — the
// same survives-reconcile guarantee the Findings "show model prompt" disclosure has. The prompt /
// reply / verdict are UNTRUSTED third-party text, rendered as JSX text (Preact auto-escapes).
// The disclosure is NATIVE and UNCONTROLLED (JEF-411): the DOM owns its open state, so an operator
// reading a long prompt keeps it open across a poll (Preact's keyed diff never disturbs it) with no
// client bookkeeping. The prompt / reply / verdict are UNTRUSTED third-party text, rendered as JSX
// text (Preact auto-escapes).

/**
* @param {object} props
* @param {number} props.i the judgement's index (the disclosure's stable key seed, matching the
* maud `judgement-{i}`).
* @param {any} props.j the judgement props (serde kebab-case).
* @param {import("../store.js").Store} props.store the client store (disclosure open state).
*/
export function JudgementEntry({ i, j, store }) {
const key = `action:judgement-${i}`;
const open = store.isDisclosureOpen(key);
export function JudgementEntry({ j }) {
return (
<li class="judgement-entry">
<details
class="model-prompt"
open={open}
onToggle={(e) => store.setDisclosureOpen(key, e.currentTarget.open)}
>
<summary class="why-toggle" role="button" aria-expanded={String(open)}>
<details class="model-prompt">
<summary class="why-toggle" role="button">
<span class="judgement-entry-key t-data-strong">{j.entry}</span>
<span class="judgement-entry-meta t-micro muted">
{" \u{00B7} reaches "}
Expand Down
15 changes: 7 additions & 8 deletions engine/web/src/action/view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,23 @@
// Reconcile keying (the only per-view variation): each row keys on its stable anchor — a would-act
// / left-alone entry on its `entry` key, a reversion on `(cut, age)`, a judgement on its `entry`
// (paired with index for stability). Preact patches each row in place across a poll, and the
// judgement `<details>` open state is KEYED COMPONENT STATE (persisted in the store) so an operator
// reading a prompt keeps it open. Every untrusted string (entry / verdict / cut / reason / prompt /
// reply) renders via JSX text interpolation (Preact auto-escapes).
// judgement `<details>` is a NATIVE, UNCONTROLLED disclosure (JEF-411) so an operator reading a
// prompt keeps it open (the DOM owns the state). Every untrusted string (entry / verdict / cut /
// reason / prompt / reply) renders via JSX text interpolation (Preact auto-escapes).

import { JudgementEntry } from "./judgement.jsx";

/**
* @param {object} props
* @param {any} props.view the Action view props (serde kebab-case).
* @param {import("../store.js").Store} props.store the client store (disclosure state).
*/
export function ActionView({ view, store }) {
export function ActionView({ view }) {
return (
<main class="view view-action">
<Headline v={view} />
<ProposedCuts v={view} />
<LeftAlone v={view} />
<JudgementAudit v={view} store={store} />
<JudgementAudit v={view} />
</main>
);
}
Expand Down Expand Up @@ -250,7 +249,7 @@ function LeftAloneEntry({ l }) {

/** Section 3 — Judgement audit (model debug): the verbatim prompt/reply per model call, as
* collapsed disclosures. Honest about an absent prompt/reply. */
function JudgementAudit({ v, store }) {
function JudgementAudit({ v }) {
const judgements = Array.isArray(v.judgements) ? v.judgements : [];
return (
<section class="activity-section judgements" aria-label="judgement audit">
Expand All @@ -267,7 +266,7 @@ function JudgementAudit({ v, store }) {
) : (
<ul class="judgement-list">
{judgements.map((j, i) => (
<JudgementEntry key={`${j.entry} ${i}`} i={i} j={j} store={store} />
<JudgementEntry key={`${j.entry} ${i}`} j={j} />
))}
</ul>
)}
Expand Down
Loading
Loading