← Cycles
Design · c108

Honest States

archivedcreated 2026-10-02· last activity 2026-10-27· mode: formula: apply a codified rule (R31) everywhere it is broken, then break it deliberately only where the design says so.

Make qdash tell the truth while it loads, when the backend is down, and when a write fails. Apply bible rule R31 ("states are honest, end to end", adopted in c107) to the surfaces that currently show reassuring numbers they haven't measured.

New in this cycle: R31 applied: '—' and skeleton rows while loading, one banner when qdash is unreachable, the all-clear only after the data loads, 'No agents reporting' instead of 100.

Why

The app shows its empty state when it is really loading or failed. On first paint, and whenever the backend is unreachable:

  • Findings says "No findings. Fleet is clean. 🛡";
  • the posture score reads a green 100;
  • Campaigns says "No campaigns yet";
  • Cost says "No spend yet.";
  • Users says "No attributed users".

Some writes swallow their failures, so the operator never learns a save didn't land. For a security control plane, a false all-clear is the worst kind of wrong. The c99 Opus track found this (SIGNOFF F3, CO2, CA1, C3, S1/S2, Δ13, K2, ND3), and c107 adopted the rule as R31 before the code was changed (opus-harvest.md rows B9–B11).

Objective

Every R31 surface distinguishes loading, failed and legitimately empty, and every write failure is visible. Done when:

  • each surface in plan.md §1 renders three distinguishable states on the embedded build: seed loading, backend stopped, and a truly empty roster;
  • no write in §1 swallows a rejection;
  • specs cover the rollup changes;
  • there is a before/after visual record.

Scope

  • qcontrol: crates/qdash/ui/app/ (appliances, lib/posture-rollups.ts, the domain composables with swallowed writes), on feat/new-pages. That branch is parked under PR #713 until Mark says it's ready; this work adds to it.
  • design: this directory; possibly a layer-candidate row if a library part lacks a loading or error port (R31 says to guard it locally and log the missing port).
  • Out of scope: the request layer (typed errors, not-found handling in the transport) and shared requests, which are separate follow-ons. Backend changes.

Key Changes

  • kick-off (2026-10-02): plan drafted from the c107 harvest's B9–B11 rows, re-verified against the shipped app on feat/new-pages. The copy and look for the loading and failed states are a design call (plan §3), and Mark makes it before any code changes.
  • decisions (2026-10-02): Mark ruled on D1–D5:
    • D1: "—" for metrics, skeleton rows for tables.
    • D2: one page banner plus "—" when the backend is unreachable.
    • D3: inline error and toast when a save fails; the control reverts.
    • D4: posture with no agents shows "—" plus "No agents reporting".
    • D5: a "last updated" tag after 2+ missed polls.

    Work starts: chassis → rollups → the false all-clears, then a review stop with screenshots.
  • part 1 (2026-10-02) — chassis + the false all-clears, review stop. qcontrol f76fa091, local on feat/new-pages, not pushed.
    • Chassis: feedState() (error first; a failed poll after a load stays ready); shell useBackendHealth, fed by the service client, where unreachable means no response, a timeout or 502–504, never a 404; the primitives mark feeds that failed with nothing loaded; layout BackendBanner (D2 banner, D5 "last updated" tag); lib SkeletonRows.
    • Rollups: postureBand averages scored harnesses only, and avg and coverage are null when nothing is measured (the spec covers it).
    • Surfaces: Findings cards + ledger; Posture tiles, score, factor bars, history and risk table (two more all-clears found there: "All instrumented harnesses are hardened", "Not enough run history"); Compliance attention; Cost tiles + breakdown; Campaigns cards; Users roster.
    • 566 specs, bible-check clean.
    • Visual record: c108-states-{before,after}.json, 19 + 19 shots, six pages × loading / failed / ready plus a stale shot. "Before" is the pre-c108 embedded build on :7882: Findings with qdash answering 503 says "0 HIGH … No findings. Fleet is clean." "After" is :3030: banner + "—", skeleton rows, and the data kept with "Last updated 11s ago · retrying".
    • capture.mts gained api: 'slow' | 'down' (page or action) to simulate the backend. It matches paths starting /api/, because nuxt dev serves source modules like /_nuxt/composables/api/….
    • Concurrent session: the c106 sticky-title port is working in the same ../qcontrol tree (PageHeader, useDetailTitle, 13 detail pages, layouts/default.vue; layer 0.9.26 committed as 2dacfeaa). Its mid-edit PageHeader briefly broke two captures; both were retaken. My commit stages only my hunks, and its files are untouched.
    • Review notes: the stale tag sits a little tight against the floating filter bar. Not yet done: the other ~60 empty-state sites, the 14 silent write catches (D3 toast + inline), and actor-bands successPct … : 100 (tool success with no judged calls).
  • part 2 (2026-10-02) — every empty state waits for its data. Mark: "got it, let's proceed" (group 1, then groups 2–3). qcontrol e042a5d4 (2a), 786405aa (2b), 26ae31f8 (bible §16 wiring), all local on feat/new-pages.
    • 2a, the remaining false all-clears: Alerts "nothing would fire ✓", Data Protection "No sensitive file access ✓" ×2 and "No secrets or PII ✓" (plus its tiles), Users "everyone covered", Identity, Posture installs, Policies custom controls. Adds lib StateEmpty (the one place an empty message decides whether it may speak), a BarList state port, and LAYER-CANDIDATES rows (SkeletonRows, StateEmpty, and the layer state port that would retire them).
    • 2b, the rest:
      • usePageState(): each feed registers with the component that created it, and a page gets one state over its running feeds; closed-gate and on-demand feeds don't count.
      • Inventory: the seven listings, where the filter-match and no-data copy both wait, and every detail panel.
      • Organization / Activity: the listings, the team panels, the six session ledgers, the history bands and their provenance captions.
      • Cost: the tables and the guardrail. Performance: the ledgers.
      • Detail pages: a skeleton while loading and "—" when down; they used to render nothing. Endpoints no longer says "not found" while loading.
      • Tools: the success rate is null with no judged calls.
      • The stale tag gets room from the filter bar.
    • Verified: scripts/qdash-r31-check.mjs (new, reusable) loads 34 routes with /api held open, answering 503, and up; all clean. The first runs caught four copies the static scan had missed: "No spend or token targets yet", "No model observed", "No harness observed" (a provenance caption) and the Endpoints "not found". 569 specs.
    • Deliberately left: Settings / Reports "No page yet" (design placeholders), the /live filter popover, the policy filter's "No policy matches", AdjudicationCard "No adjudications yet" (only inside an already-loaded finding), and policy-state percent … : 100 for a framework with no required controls (a definitional question, not a loading one).
    • Next: part 3, the writes. A toast (layer candidate) plus inline errors for the 14 silent catches, with the control reverting (D3), each forced to fail on a scratch seed.
  • pushed (2026-10-02, from another session): Mark asked for the branch to go to a colleague, so parts 1–2 and the part 3 work in the tree were committed and pushed to origin feat/new-pages.
    • Parts 1–2 (f76fa091, e042a5d4, 786405aa, 26ae31f8) and the layer 0.9.27 bump (5f24a817) were pushed as they were.
    • Part 3 is committed as 686cf266 ("honest states, part 3 — a failed save says so"): the 26 files the tree held at 13:23. That covers useToast + failReason, ToastStack in the default layout, and the inline save errors across policies, the Govern sheet, controls and the evidence packet, campaigns, findings, integrations and the Users roster.
    • Checked before committing: 575 specs, bible-check clean, and /policies, /integrations, /campaigns and /organization/users all render on :3030.
    • Not done: no save was forced to fail, so no toast has been seen, and the "each forced to fail on a scratch seed" pass above is still open. If part 3 wasn't finished at 13:23, carry on from 686cf266.
  • part 3 (2026-10-02) — a failed save says so (D3). Mark: "go ahead". qcontrol 686cf266. That commit was made by a concurrent session (the c109 nav thread) during a commit sweep of the shared tree. It contains exactly this thread's part-3 changes with an accurate message; that session then regenerated the dist with c108 parts 2–3 in it (bf351751).
    • Shell useToast + failReason ("qdash is unreachable" vs "qdash rejected it") + ToastStack in the layout. Lib candidate: UxToast.
    • Silent writes that now report: campaign remove; evidence-packet export; egress decisions on Policies and the policy detail (now shown on P10's own save line); integration routing chips, save, disconnect and the setup modal (the last three had no catch at all, or a bare return false); team assignment on the Users roster.
    • Writes that already had an inline error now also toast (D3 "both"): campaign create, finding decisions, the Findings egress modal, control save, threshold save.
    • Copy: "save failed — the backend rejected the control" (said even when qdash didn't answer) became "✕ Couldn't save the control — try again".
    • Controls revert to server truth on failure.
    • Verified: scripts/qdash-d3-check.mjs (new) forces 12 write paths to fail. Each failing request gets a 503 from Playwright and never reaches the seed. All 12 show inline + toast. Not driven by the script but sharing tested code: the P15/P17 threshold editors and the Policies-list Approve/Block buttons. Live agent focus/reveal are intentional fallbacks and stay as they are.
    • Twice during the run, the shared dev server served stale transforms of files I'd edited while other sessions were committing in the same tree. touching the changed files fixed it.
  • embedded build (2026-10-02). Release binary from HEAD (bf351751, dist includes c108), on a fresh seed copy: qdash-r31-check 34/34 routes clean in slow / down / up; qdash-d3-check 12/12.
  • close-out (2026-10-02). Mark: "make it so, screenshots, history, anything else".
    • qcontrol 1154ae0a, four more sites found by the final captures and a wider sweep:
      • Data Protection "No sensitive access by user";
      • Compliance's per-control "No active findings" (green);
      • harness detail "No at-rest installs scanned";
      • Surface panels, which no longer blame the build ("not served by this backend — restart qdash") when qdash is simply unreachable. The not-served note stays for a real 404.
    • e30d5220: the stale-data notice. A concurrent session (5e64694f) restyled it to float bottom-right but used absolute, which anchored it to the document's bottom, below the fold on any long page (Users: y=1273 on a 900 px window). fixed restores the intended float, and the toast stack moves to bottom-16 so the two never overlap.
    • Dists: 16c403df, eec2211e.
    • Final record: c108-states-final.json, 13 shots in thread "final". Loading and failed states across Findings, Posture, Data Protection, Alerts, Compliance, Cost, Models, a tool detail page and Users; the stale notice; two failed saves with their toasts (evidence export, a routing chip).
    • capture.mts gained fail:/api/x (writes only) and failget:/api/x.
    • qdash-r31-check gained the four new phrases. Both checks pass on a freshly restarted dev server: 34/34 and 12/12.
    • Two operational lessons:
      • With several sessions committing in one tree, the dev server's watcher drops events and serves stale transforms. A restart is the reliable fix.
      • make qdash-ui runs npm ci in the same directory and breaks a live nuxt dev (503) until it restarts.

Outcome

qdash now tells the truth about its own state (bible R31). Loading, failed and empty are three different things on every page: "—" and skeleton rows while data loads, one banner when qdash is unreachable, and the empty or all-clear copy only after the data has really arrived. A failed save says so beside the control and in a toast, and the control reverts.

  • The false all-clears are gone: "Fleet is clean", posture 100, coverage 100%, "all 0 findings mapped", "No sensitive file access ✓", "Nothing would fire ✓", "everyone covered", and "No active findings" per control. In their place: "—", or "No agents reporting" (D4).
  • Mechanism: feedState() / usePageState() (feeds register with the page that created them), lib StateEmpty, SkeletonRows, BarList state, shell useBackendHealth + BackendBanner (unreachable ≠ rejected), useToast + failReason. Documented in bible §16 "How it's wired".
  • Scope: about 70 empty-state sites, 13 detail pages, 4 rollups that invented 100, and 15 write paths (8 were silent, 3 had no catch at all).
  • Verified by two reusable checks:
    • scripts/qdash-r31-check.mjs: 34 routes with /api held open, answering 503, and up.
    • scripts/qdash-d3-check.mjs: 12 write paths forced to fail.

    Both pass on the release binary built from source and on the dev server.
  • Visual record: 19 + 19 before/after shots (part 1) and 13 final shots, all subject-keyed into /cycles/evolution.
  • qcontrol commits on feat/new-pages, local, parked under PR #713:
    • f76fa091, e042a5d4, 786405aa (parts 1–2a/2b);
    • 26ae31f8 (bible);
    • 686cf266 (part 3, committed by a concurrent session's sweep);
    • 1154ae0a, e30d5220 (close-out);
    • dists bf351751, 16c403df, eec2211e.
  • Carries forward:
    • layer ports for DataTable / UxTableList / lists (a state port), UxToast, DataSkeletonRows and DataStateEmpty (LAYER-CANDIDATES);
    • the shared-request engine and typed transport errors (c107 follow-ons);
    • policy-state percent … : 100 for a framework with no required controls, which is a definitional question.

Visual record

51 captures — heroes, page slices, and full-page renders. Click to view.

Honest states — final (parts 1–3)

2026-10-02· 13 captureslatest
Findings with qdash unreachable: the banner, every count "—", no "Fleet is clean"
Findings with qdash unreachable: the banner, every count "—", no "Fleet is clean" history · 9 moments →
Posture loading: "—" tiles, skeleton factors and risk table — no score of 100, no "all hardened"
Posture loading: "—" tiles, skeleton factors and risk table — no score of 100, no "all hardened" history · 6 moments →
Posture with qdash unreachable: banner + "—" in every tile and panel
Posture with qdash unreachable: banner + "—" in every tile and panel history · 6 moments →
Data Protection with qdash unreachable: no "No sensitive file access ✓", tiles "—"
Data Protection with qdash unreachable: no "No sensitive file access ✓", tiles "—" history · 4 moments →
Alerts with qdash unreachable: no "Nothing an alerting policy would fire on ✓"
Alerts with qdash unreachable: no "Nothing an alerting policy would fire on ✓" history · 5 moments →
Compliance loading: the attention strip waits instead of "✓ all 0 findings mapped"
Compliance loading: the attention strip waits instead of "✓ all 0 findings mapped" history · 9 moments →
Cost Center loading: spend tiles "—", breakdown and tables wait, no "No spend yet"
Cost Center loading: spend tiles "—", breakdown and tables wait, no "No spend yet" history · 6 moments →
Models listing loading: skeleton rows, not "No model usage captured yet"
Models listing loading: skeleton rows, not "No model usage captured yet" history · 7 moments →
A detail page loading: skeleton (it used to render nothing)
A detail page loading: skeleton (it used to render nothing) history · 4 moments →
Users with qdash unreachable: no "everyone covered", roster waits
Users with qdash unreachable: no "everyone covered", roster waits history · 7 moments →
Data on screen, then qdash drops: the data stays, with a "Last updated · retrying" tag
Data on screen, then qdash drops: the data stays, with a "Last updated · retrying" tag history · 7 moments →
A failed save (D3): the evidence export fails — inline ✕ beside the button and a toast
A failed save (D3): the evidence export fails — inline ✕ beside the button and a toast history · 6 moments →
A failed save (D3): a routing chip rejected — inline ✕ on the panel, a toast, the chip reverts
A failed save (D3): a routing chip rejected — inline ✕ on the panel, a toast, the chip reverts

Honest states — after (R31)

2026-10-02· 19 captures
After · /posture · loading (/api held open)
After · /posture · loading (/api held open) history · 6 moments →
After · /posture · failed (/api answering 503)
After · /posture · failed (/api answering 503) history · 6 moments →
After · /posture · ready
After · /posture · ready history · 6 moments →
After · /findings · loading (/api held open)
After · /findings · loading (/api held open) history · 9 moments →
After · /findings · failed (/api answering 503)
After · /findings · failed (/api answering 503) history · 9 moments →
After · /findings · ready
After · /findings · ready history · 9 moments →
After · /compliance · loading (/api held open)
After · /compliance · loading (/api held open) history · 9 moments →
After · /compliance · failed (/api answering 503)
After · /compliance · failed (/api answering 503) history · 9 moments →
After · /compliance · ready
After · /compliance · ready history · 9 moments →
After · /cost · loading (/api held open)
After · /cost · loading (/api held open) history · 6 moments →
After · /cost · failed (/api answering 503)
After · /cost · failed (/api answering 503) history · 6 moments →
After · /cost · ready
After · /cost · ready history · 6 moments →
After · /campaigns · loading (/api held open)
After · /campaigns · loading (/api held open) history · 6 moments →
After · /campaigns · failed (/api answering 503)
After · /campaigns · failed (/api answering 503) history · 6 moments →
After · /campaigns · ready
After · /campaigns · ready history · 6 moments →
After · /organization/users · loading (/api held open)
After · /organization/users · loading (/api held open) history · 7 moments →
After · /organization/users · failed (/api answering 503)
After · /organization/users · failed (/api answering 503) history · 7 moments →
After · /organization/users · ready
After · /organization/users · ready history · 7 moments →
After · /organization/users · data loaded, then the backend drops for ~10 s
After · /organization/users · data loaded, then the backend drops for ~10 s history · 7 moments →

Honest states — before (pre-c108 build)

2026-10-02· 19 captures
Before · /posture · loading (/api held open)
Before · /posture · loading (/api held open) history · 6 moments →
Before · /posture · failed (/api answering 503)
Before · /posture · failed (/api answering 503) history · 6 moments →
Before · /posture · ready
Before · /posture · ready history · 6 moments →
Before · /findings · loading (/api held open)
Before · /findings · loading (/api held open) history · 9 moments →
Before · /findings · failed (/api answering 503)
Before · /findings · failed (/api answering 503) history · 9 moments →
Before · /findings · ready
Before · /findings · ready history · 9 moments →
Before · /compliance · loading (/api held open)
Before · /compliance · loading (/api held open) history · 9 moments →
Before · /compliance · failed (/api answering 503)
Before · /compliance · failed (/api answering 503) history · 9 moments →
Before · /compliance · ready
Before · /compliance · ready history · 9 moments →
Before · /cost · loading (/api held open)
Before · /cost · loading (/api held open) history · 6 moments →
Before · /cost · failed (/api answering 503)
Before · /cost · failed (/api answering 503) history · 6 moments →
Before · /cost · ready
Before · /cost · ready history · 6 moments →
Before · /campaigns · loading (/api held open)
Before · /campaigns · loading (/api held open) history · 6 moments →
Before · /campaigns · failed (/api answering 503)
Before · /campaigns · failed (/api answering 503) history · 6 moments →
Before · /campaigns · ready
Before · /campaigns · ready history · 6 moments →
Before · /organization/users · loading (/api held open)
Before · /organization/users · loading (/api held open) history · 7 moments →
Before · /organization/users · failed (/api answering 503)
Before · /organization/users · failed (/api answering 503) history · 7 moments →
Before · /organization/users · ready
Before · /organization/users · ready history · 7 moments →
Before · /organization/users · data loaded, then the backend drops for ~10 s
Before · /organization/users · data loaded, then the backend drops for ~10 s history · 7 moments →

In the archive

Artifacts

/campaigns (local dev)/compliance (local dev)/cost (local dev)/findings (local dev)/posture (local dev)/organization/users (local dev)/alerts (local dev)/dlp (local dev)/policies (local dev)/integrations/pagerduty (local dev)/inventory/models (local dev)/inventory/tools/detail?tool=Glob&kind=builtin (local dev)

Qpoint Brand Style Guide