diff --git a/docs/adr/0028-dashboard-client-local-state-simplification.md b/docs/adr/0028-dashboard-client-local-state-simplification.md new file mode 100644 index 0000000..35e9c4f --- /dev/null +++ b/docs/adr/0028-dashboard-client-local-state-simplification.md @@ -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 `
` 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 `
`**; 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2397930..26b1a5c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 (`` + `#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 `
`; 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. diff --git a/engine/web/package-lock.json b/engine/web/package-lock.json index 923eacf..ee9e462 100644 --- a/engine/web/package-lock.json +++ b/engine/web/package-lock.json @@ -8,15 +8,11 @@ "name": "protector-dashboard", "version": "0.0.0", "license": "UNLICENSED", - "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" } }, @@ -1771,6 +1767,7 @@ "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild-wasm/-/esbuild-wasm-0.25.10.tgz", "integrity": "sha512-IyyfrTA2iiOh/uhlaJj0aUDgW42lFhr29ZeKouVNOz/8mLyuqWbEuVst+B4RBH18pb3AcOHnaOgyskAbsVOe3A==", + "dev": true, "license": "MIT", "bin": { "esbuild": "bin/esbuild" @@ -2641,6 +2638,7 @@ "version": "10.29.7", "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "dev": true, "license": "MIT", "funding": { "type": "opencollective", diff --git a/engine/web/package.json b/engine/web/package.json index 718e0a9..b0989e1 100644 --- a/engine/web/package.json +++ b/engine/web/package.json @@ -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" } } diff --git a/engine/web/src/action/judgement.jsx b/engine/web/src/action/judgement.jsx index 2376717..81edce7 100644 --- a/engine/web/src/action/judgement.jsx +++ b/engine/web/src/action/judgement.jsx @@ -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 `
` 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 `
` 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-`) 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 (
  • -
    store.setDisclosureOpen(key, e.currentTarget.open)} - > - +
    + {j.entry}