---
title: "Local Dashboard (nova serve) — User Reference"
description: "The dashboard is an opt-in local-only HTTP server (nova serve --experimental) that renders an interactive view over the same registry SQLite, lineage…"
canonical: https://novafabric.ai/docs/dashboard/
last_updated: 2026-10-24
source: https://github.com/MSKazemi/novafabric/blob/main/docs/dashboard.md
---

# Local Dashboard (`nova serve`) — User Reference

**Status:** Experimental. Read this entire page before relying on the dashboard for anything that matters.
**Per:** [ADR-0027](https://novafabric.ai/docs/decisions/), [Non-goals](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md#what-novafabric-is-not)
**First shipped:** v0.7

The dashboard is an **opt-in local-only HTTP server** (`nova serve --experimental`) that renders an interactive view over the same registry SQLite, lineage SQLite, and capsule directories the CLI reads. The CLI is the canonical interface; the dashboard is a satellite that surfaces the equivalent CLI command for every action.

---

## Dashboard tabs (complete inventory)

The dashboard ships **30 tabs** in **7 balanced workflow groups** (Overview, Runs & Debug, Govern & Promote, Provenance & Trust, Compliance, Platform, Reports & Export). Every tab has a stable two-key navigation shortcut — press `g` then the tab's key (shown on hover and in the `?` overlay). **Since v0.98.3 the sidebar groups start collapsed** (each showing its tab count); arriving at a tab opens its group, and that group can still be collapsed afterwards (a marker on the collapsed header shows where the active tab is); manual expand/collapse persists per browser in `novafabric.sidebar-groups`. Below **1024px** the sidebar auto-collapses to the icon rail (v0.97.0); expanding it explicitly wins for the rest of the session. (Source of truth:
`ui/dashboard/src/components/dashboard/Sidebar.tsx`; verified by count against
`NAV_GROUPS` in that file.)

**Deep links.** Every tab is addressable as `?tab=<id>`, and the Compliance tab is a hub
whose five panel groups are addressable as `?tab=compliance&sub=<group>`
(`frameworks` · `audits` · `privacy` · `exports` · `assurance`, v0.97.0) — the same groups
the ⌘K palette lists. **Since v0.98.2 all three navigation paths write the URL**: clicking a
sidebar item, a `g`-key sequence, and the command palette all update `?tab=` (and clear a
stale `?sub=` when you leave the hub), so any view you reach by keyboard is shareable and
survives a reload. Tab ids have not changed since before the v0.97.0 regrouping, so existing
links still resolve.

**Runs view state in the URL** *(experimental, since v0.103.0 — ADR-0232 D2)*. The whole Runs
view is in the URL, so a pasted link reproduces exactly what you were looking at:

| Key | Meaning | Example |
|---|---|---|
| `q` | free-text search on run id / command | `q=agent.py` |
| `status` | status chip | `status=error` |
| `sort` | list order | `sort=longest` |
| `since` / `until` | date window (`YYYY-MM-DD`, `until` inclusive) | `since=2026-09-01` |
| `f` | filter-bar text (below) | `f=status:error%20-model:gpt-4o` |
| `scope` | ADR-0233 scope for `f`: `node` · `root` · `tree` | `scope=tree` |
| `run` | the selected run — opens the inspector even if the run is not in the loaded page | `run=01J…` |
| `view` | inspector view: `inspect` · `trace` · `integrity` · `lineage` · `secrets` · `forensics` · `children` | `view=integrity` |

Applying a filter, changing scope, status or dates adds a browser-history entry, so **Back
undoes it**; typing and keyboard selection do not. Unknown values fall back to the default
rather than rendering a state the UI cannot show. Switching to another tab drops these keys.
What is deliberately *not* in the URL: the half-finished Compare selection (ADR-0036),
checkboxes, unsubmitted filter text, and the *Replay result* view (it shows a result
produced in your session, so a link to it would open on nothing).

### Filter bar *(experimental — ADR-0232 D1/D3, ADR-0233)*

Under the Runs search box. Type terms, press **Enter** to apply:

```text
status:error                      # dimension = value
-model:gpt-4o                     # exclude
status:error asset:"my agent"     # terms are ANDed; quote values with spaces
```

- **It is `nova query --where`, not a second query language.** The server parses with the
  DSL's own parser, so the filterable fields are exactly the DSL's dimensions — `asset`,
  `deployment_environment`, `variant`, `log_level`, `model`, `model_id`, `status`, `tag`.
  Metrics (`cost:>0.5`) and wildcards (`model:gpt-4*`) are **rejected with an explanation**,
  because `nova query` cannot filter by them either. The equivalent `nova query` command is
  shown under the results with a copy button.
- **Suggestions are observed values.** After `model:` the bar lists models actually present
  in the last 30 days (↑/↓ to move, Enter to complete). When more than 100 exist the list
  says it is **partial** rather than implying the value does not exist.
- **Scope** (ADR-0233): `node` lists the matching runs; `root` lists the root run of every
  capsule tree containing a match; `tree` lists every capsule in those trees, siblings
  included.
- **Honest results** (ADR-0234 D2): at most 200 runs are listed; the header says
  "N of M" and *truncated* when there are more, and a tree that is still filling or a
  capsule that could not be read is listed as a reason the result may be incomplete.
- While a filter is applied it owns the list: free-text search and the status chips are
  disabled (use `status:` in the bar). The date window still applies.
- **Saved views are `nova view` files** (ADR-0232 D4, experimental). "Save current as" writes
  an ADR-0130 view that `nova view show` and `nova view run` read, into the same directory
  (`$NOVAFABRIC_VIEWS_DIR` or `./.novafabric/views`), through the same fail-closed parser;
  there is no second store. What is saved: the filter bar's predicates (the status chip
  becomes a `status` predicate), the scope and the date window; sort is an advisory display
  preference. **Free-text search is not saved**, because `nova query` cannot express it, and
  the bar says so. A view saved from the CLI is offered back when the bar can express it, and
  listed disabled with the reason when it cannot (an `IN` or comparison predicate); it is
  never approximated. If the server routes are unavailable the bar falls back to
  browser-local views and says so. Verify from the CLI: `nova view show <id>`,
  `nova view run <id>`.

API (all `read` scope, token required): `GET /api/filter/parse?f=`,
`GET /api/filter/runs?f=&scope=&since=&until=&limit=`, `GET /api/filter/suggest?dimension=&since=`.
Saved views: `GET /api/views` (`read`), `POST /api/views` and `DELETE /api/views/{view_id}`
(`operate`, audited).

### Dashboards view *(experimental — ADR-0235 / ADR-0236)*

Sidebar group **Overview**, key `g` then `4` (deep link `?tab=dashboards`). It lists the
dashboards and widgets under `$NOVAFABRIC_HOME/dashboards` and renders every schema chart type
(`line`, `bar`, `area`, `table`, `stat`). The files are the storage; there is no database of
user dashboards. The same files are managed from the CLI with `nova dashboard
list|validate|apply|show|export` ([CLI reference](https://novafabric.ai/docs/cli-reference/dashboard/#nova-dashboard)).

- **Refusals are visible.** A file the validator refuses is listed by name with the
  validator's reason, so one bad paste cannot blank the page. A dashboard that references a
  missing or refused widget still shows that panel, says why, and is flagged incomplete.
- **Absent is not zero** (ADR-0234 D2). An undefined value reads "no value" and charts draw
  it as a gap; a measured `0` stays `0`. A `ratio()` (ADR-0236) is always shown with its
  numerator and denominator, and an undefined one says why (zero denominator, absent operand).
- **Export** each widget or dashboard as its stored JSON, byte for byte, so unknown fields
  survive.
- **Add or edit one document** (paste or upload JSON, or a guided widget form). **Validate**
  is a dry run (`POST /api/dashboards/validate`, `read` scope, writes nothing): the server's
  own verdict, from the same loaders `nova dashboard validate|apply` use, so the UI cannot
  accept what the CLI refuses. An accepted document is previewed as a diff against the stored
  bytes; **Save** (`POST /api/dashboards/apply`, `operate` scope, audited as
  `dashboard_apply`) stores exactly the previewed bytes, atomically. It refuses an id outside
  the path pattern, a symlinked target, a `builtin` document, and a body over 256 KiB (413).
  Saving sends the digest of the file the preview showed, so a file changed meanwhile is a 409
  rather than a silent overwrite.
- **Not built:** deleting from the UI, forking a `builtin` document on first edit, and
  bulk directory installs (use `nova dashboard apply <dir>`).

Read routes (`read` scope): `GET /api/dashboards`, `/api/dashboards/{id}`,
`/api/dashboards/{id}/export`, `/api/dashboard-widgets/{id}/data` (runs only the query stored
in the widget file; the request carries no query text) and `/api/dashboard-widgets/{id}/export`.

### Capsule explorer *(experimental)*

Selecting a run opens the inspector with a **summary strip** — status, start time,
duration, exit code, model/tool call counts (mutating tools called out), the command — and
the run's actions: **Replay dry-run**, **Forensic replay**, **Export evidence** (each through
the usual confirm dialog with its CLI equivalent) and **Copy link**. The view switcher is a
keyboard tablist (←/→, Home/End):

- **Inspect / Trace** — manifest, model and tool calls, waterfall (unchanged).
- **Integrity** — runs `nova verify` on demand. The outcomes are kept distinct: *not
  sealed* (nothing to verify — not a failure), *verified*, and *failed* with each failing
  check (signature, RFC 3161 timestamp, Merkle log inclusion) named. Without a
  `novaseal.yaml` on the server the capsule is verified from its own files, as
  `nova verify` does, and the verdict says so ("verified from the capsule alone": no
  local Merkle log, no operator trust anchors). *Sealed but not verifiable here* appears
  only against an older server that returns no checks. The bundled static dashboard
  picks this up after the next dashboard rebuild.
- **Lineage** — the run's spool-lineage edges as neighbours in both directions; click one
  to open it (even if it is not in the loaded list) or **Compare** it with this run.
- **Secrets / Forensics / Children** — unchanged.

### Evidence cart *(experimental — ADR-0239)*

Collect runs while you investigate, then export them as **one** signed Evidence Bundle.

- **Add**: the **+ cart** button on a run's action row, or tick several runs and press
  **+ cart** in the selection banner. The cart bar appears above the Runs view only once it
  holds something.
- **What the cart holds**: references (`run` + id), each stamped with when it was added and
  the view URL it was added from. Nothing is copied at add time — every reference is read
  **once, at export**, so the bundle reflects one point in time. The cart lives in this
  browser session (`sessionStorage`) and is never stored on the server; closing the session
  clears it.
- **Export cart** needs an **`admin`** credential and asks for confirmation. The server
  resolves every reference, writes the bundle to `~/.novafabric/evidence/cart-<id>-<time>.zip`
  (or `$NOVAFABRIC_EVIDENCE_DIR`), and shows its path, SHA-256 and the command that checks
  it: `nova verify <bundle>`. The bundle carries a `curation.json` (covered by the manifest
  hash) stating it is an **operator-curated subset**, never an exhaustive one, with each
  item's provenance.
- **Unresolvable references stop the export.** A run that no longer exists is listed with
  the reason, and nothing is written. **Export anyway and record the omissions** proceeds and
  records the missing items in `curation.json`; there is no path that silently produces a
  smaller bundle. Cart kinds other than `run`/`capsule` (charts, diffs, lineage queries) have
  no resolver yet and are reported as unresolved (**planned**).
- **Legal holds**: held runs export, and the bundle says so (`contains_held_evidence`, hold
  ids per item). The export never places, releases or changes a hold.
- **Audited, or not exported**: each export appends an `evidence.export` entry to the
  hash-chained audit log (`$NOVAFABRIC_AUDIT_LOG_PATH`, else `$XDG_DATA_HOME/novafabric/audit.jsonl`,
  else `~/.local/share/novafabric/audit.jsonl`) naming the actor, the items and the bundle digest, plus a
  dashboard audit record. If the chained entry cannot be written, the bundle is deleted and
  the export answers **503**.
- **Bounds**: at most **50 items** and **256 MiB** of capsule content per export, one export
  at a time per server (**429** otherwise). A cart that resolves to a single run is refused
  — use the run's own **Export evidence** action (`nova export-evidence`).

API: `POST /api/evidence/cart/export` (`admin` scope) with
`{items: [{kind, ref, added_at, added_from?, note?}], confirmed: true, accept_unresolved?}`.

### Honest aggregates and the Runs aggregate strip *(experimental — ADR-0234)*

Every run aggregate the dashboard serves now carries an `aggregate` verdict. When the number
cannot be computed faithfully, the endpoint **refuses** — it says why and what would make it
computable — instead of returning a zero or a partial total.

| Endpoint | Refuses when | What the response holds then |
|---|---|---|
| `GET /api/analytics/summary` | no runs index while capsules exist (`source_unavailable`); the index holds fewer runs than the capsules on disk (`truncated_source`); the caller passes a filter-bar text `f`, a status other than `all`, or a search `q` it cannot apply (`unpushable_filter`) | `totals: null`, no buckets |
| `GET /api/stats` | the runs index is behind the disk (`truncated_source`) | `run_count`, `failed_run_count`, `passed_run_count`: `null` (asset counts unaffected) |
| `GET /api/cost/report` | the store did not answer (`source_unavailable`); **every** call in the window is unpriced (`absent_contributor`) | `totals: null`, or `cost_usd: null` with the exact token counts kept |
| `GET /api/reports/{cost-burn,throughput,executive-summary}` and their `/export` artifacts | the runs index is behind the disk (`truncated_source`) | JSON: no rows; CSV and HTML/PDF export: **409** with the verdict, so no downloaded file carries the numbers without the refusal |
| `GET /api/runs/cost-summary` | (unchanged — the first consumer of the rule) | |

- **A measured zero stays zero.** An empty window over a complete index is a computable `0`;
  there is no refusal for "the number is small".
- **Partly unpriced cost is a stated lower bound**: computable, with `unpriced_models`,
  `unpriced_calls` and `cost_is_lower_bound: true` in the verdict's notes.
- **Small samples are flagged, not refused.** Each analytics bucket carries
  `duration_samples`; buckets whose p95 rests on fewer than 20 runs are listed in
  `notes.small_sample_buckets`.
- **A success rate over zero runs is `null`** (undefined) in the executive summary, not `0.0`.
- The ClickHouse branch of `/api/cost/report` does not read pricing coverage
  (`pricing_coverage_checked: false` in the notes); `/api/runs/cost-summary` does.

**The Runs aggregate strip** sits above the run list: one bar per day over the current date
window, with a runs / failed / p95 toggle and the window's totals. It sends the Runs view
state to the server, so with a filter, status chip or search applied the strip shows the
refusal — *the run aggregate can only be narrowed by date…* — and its toggles are disabled
with that reason as their tooltip, rather than charting a different set of runs than the
list. Analytics, Home and Reports render refusals the same way. Clicking or dragging across
bars to filter (ADR-0234 D1 navigation) is **planned**.

| Tab | Group | Since | What it does |
|---|---|---|---|
| Home | Overview | v0.8 | Journey cards, status bar, resume session |
| Analytics | Overview | v0.62.0 | Time-bucketed run analytics from the runs index: volume + failure stacked bars, duration p50/p95 lines, stat tiles, 7/30/90-day ranges, chart/table toggle (`/api/analytics/summary`) |
| Alerts | Platform | v0.63.0 | Operational alerts feed (quota/rate-limit/policy/drift/seal/backup) + delivery outcomes from the audit log; severity badges, stat tiles, live refresh (`/api/alerts/recent`, ADR-0192) |
| Runs | Runs & Debug | v0.7 | Run list, search/filter, inspect capsule, validate, replay, verify; capsule tree, run lineage edges, secret scan (v0.46.0); **v0.64.0:** per-run **Forensics** timeline view (`/api/runs/{id}/forensics-timeline`, P5) + saved filter presets (E2); **v0.103.0 (experimental):** filter bar (`/api/filter/*`, ADR-0232/0233), full view state in the URL, capsule explorer with summary strip + **Integrity** and **Lineage** views — see [Runs view state](#dashboard-tabs-complete-inventory) |
| Diff | Runs & Debug | v0.7 | N-run comparison (2–5), word-level diff, mutation badges |
| Registry | Govern & Promote | v0.7 | Asset lifecycle: eval, promote, rollback, register, suggest-register, unregister |
| Governance | Govern & Promote | v0.16.0 | Classify (EU AI Act/NIST/OMB), audit (6 profiles), export-examiner, policy sign |
| Eval | Govern & Promote | v0.55.0 | Eval suites (`eval list`), run a suite (`eval run`), regression compare (`eval compare`) |
| Risk | Govern & Promote | v0.55.0 | OWASP LLM assurance (`assure`), secret scan, failure attribution (`diagnose`), risk-tier classify, MCP scan |
| Lineage | Provenance & Trust | v0.7 | Provenance / blast-radius / replay-chain DAG, interactive query, OpenLineage/PROV export |
| KG | Provenance & Trust | v0.17.0 | Capsule knowledge graph: query, audit, entity queue, alias mgmt, ingest |
| Dashboards | Overview | v0.103.0 (experimental) | Portable ADR-0235 dashboards and widgets from `$NOVAFABRIC_HOME/dashboards`, `ratio()` (ADR-0236), validate-and-save editor; `g 4` (see [Dashboards view](#dashboards-view-experimental--adr-0235--adr-0236)) |
| Cost | Overview | v0.17.0 | Cost report, pricing, burn analysis (ClickHouse-backed); **v0.64.0:** cost-analytics tools trio — attribution / fairness / usage-breakdown (`/api/cost/{attribute,fairness,usage-breakdown}`, P6) |
| Schema | Compliance | v0.17.0 | Schema registry (JSON Schema + proto3) |
| Evidence | Provenance & Trust | v0.9 | Bundle list, server-side verification (the `nova verify <bundle>.zip` verifier + TSR + Merkle), download |
| Audit | Provenance & Trust | v0.7 | Dashboard mutation audit log, action-type filter |
| Holds | Govern & Promote | v0.11 | Place/release legal holds, sidebar badge count |
| Policy | Govern & Promote | v0.11 | Interactive OPA/Rego check, ALLOW/DENY badge, explain trace |
| Seal | Provenance & Trust | v0.13.0 | NovaSeal verify, Sigstore sign/verify, linked-envelope proposals, bypass; **v0.98.0:** trust-surface views — **Trust Radar** SVG glyph (ADR-0173) and **Redaction X-Ray** heat overlay + coverage meter (ADR-0174), reading `/api/runs/{run_id}/{trust-radar,redaction-xray}`. An unverifiable guarantee renders `n/a` (hollow tick, excluded from the claim polygon), never `fail`; the X-Ray shows paths and states only — never a field value |
| Spine | Provenance & Trust | v0.56.0 | Accountability Spine: per-run energy receipts + conservation check (ADR-0093), append-only ledger verify (ADR-0094), structured safety case (ADR-0095) — read-only (`/api/runs/{id}/{energy,ledger,safety-case}`) |
| Compliance | Compliance | v0.15.2 | GDPR RoPA, AI-SBOM, NIST RMF, CRA coverage, erasure, audit bundle/verify (8+ panels) |
| Incidents | Compliance | v0.55.0 | EU AI Act Art. 73 incident records with a live reporting-deadline clock; open/list/transition/export (AIM, NIS2) |
| Capture | Runs & Debug | v0.7 | Capture matrix (4 runners + distributed + MCP/API proxies), adapters, recent capsules |
| Infra | Platform | v0.12.6 | Phase 0–6 component status cards (NovaSeal, Collector, OCS, MetaDB, Lineage, …); **v0.64.0:** **Backups** card (`/api/infra/backups`, P7 — read-only manifest listing) + **Maintenance** card of idempotent confirm-gated safe mutations (reindex-runs / topology re-seed / KG rebuild, P8-P10) |
| Storage | Platform | v0.55.0 | WORM object-store stats/validate/inspect, manifest chain, collector status, gated DB upgrade + rebuild-metadata-db, system-card export |
| Ops | Platform | v0.55.0 | Installation diagnostics (`doctor`), warm-daemon liveness (read-only), JWKS flush |
| Admin | Platform | v0.7 | Token management, role management (API v0.14.3; UI partial), JWKS flush, new-run-id, DB ops |
| Commands | Platform | v0.8 | 13 hand-curated command builders at launch; **v0.60.0:** now mirrors the **complete** CLI (every `nova` command, 374 today — the `// Commands:` header of `generatedCommands.ts`) via an auto-generated registry — see the CLI-parity row below |
| Reports | Reports & Export | v0.17.0+ | 9 report templates (see below) |
| Export | Reports & Export | v0.64.0 | Export Center hub (E1) — the server-catalog-driven registry export panel (`/api/compliance/export/kinds`) plus a jump-link directory to the evidence / lineage / reports / compliance export surfaces |

**Reports tab — 9 templates:** run-history, eval-regression, capsule-compare, cost-burn,
throughput, evidence-inventory, policy-audit, seal-verification, release-comparison.

**Compliance tab panels:** GDPR Art.30 RoPA, AI-SBOM (CycloneDX ML-BOM), NIST AI RMF, AI-SBOM
coverage/CRA deadline, GDPR erasure request/status (real DEK destruction since
ADR-0210, **experimental** — PII is unreadable afterwards only in capsules captured with `--pii-subject` (ADR-0314), `confirmed: true` required), audit coverage/bundle/verify, C2PA,
RO-Crate, EU AI Act Art.12.

**Governance tab panels:** risk classification, audit-profile coverage, examiner export, policy
signing, regulatory vocabularies + manual classification + eval suites/run (v0.46.0).

**v0.46.0 parity additions (12 panels across 7 tabs):** eval suites list + eval run
(Governance), policy inventory + policy sign (Policy), regulatory vocabularies + manual
classification (Governance), generate AI-SBOM (Compliance), reindex capsules (Admin),
distributed capsule tree + run lineage edges + secret scan with `--fail-on` gate (Runs),
lineage-store deployment profile (Infra).

**v0.55.0 coverage additions:** 5 new tabs (Eval, Risk, Storage, Incidents, Ops) plus
**Seal → Ratchet** (`seal ratchet init/rotate/status`) and **Evidence → Assertions**
(`evidence completeness/bind`) panels, surfacing ~30 previously CLI-only commands. Destructive /
process-control actions (DB rebuild, ratchet rotate) are confirmation-gated and daemon control is
read-only over HTTP ("safe mutations only"). New cross-cutting UX: app-wide toast notifications, a
unified action/confirm pattern, a virtualized data table, per-tab help, deep-linkable tabs
(`?tab=`), and global entity search (runs/assets/incidents) in the ⌘K palette.

> The dashboard server declares **217 REST route handlers** (counted 2026-10-23 as
> `@app.*` / `@router.*` `get/post/put/delete/patch` decorators under
> `src/novafabric/serve/`, excluding WebSocket routes; 3 of them are the TV-5 router
> that only `nova serve --tv5`/`--topology` mounts). The runtime OpenAPI document lists
> 210 operations (216 with `--topology`), because a few routes are excluded from the
> schema — the full machine list is in [`api-reference.md`](https://novafabric.ai/docs/api-reference/) (and
> `GET /api/openapi.json` at runtime).

---

## Install + run

```bash
# 1. Install the optional [serve] extra (FastAPI + uvicorn, both Tier-A licenses)
pip install 'novafabric[serve]'

# 2. Start the server (--experimental is mandatory)
nova serve --experimental

# 3. Open the URL the panel prints (it embeds the session token).
# Or paste the token manually if you prefer to type the URL.
```

Notable flags:

| Flag | Default | Purpose |
|---|---|---|
| `--experimental` | (required) | Acknowledges the experimental gate. |
| `--port` | `4321` | TCP port. |
| `--host` | `127.0.0.1` | Bind address. Refuses non-localhost without the hidden `--insecure` flag. |
| `--capsule-dir` | `$NOVAFABRIC_CAPSULE_DIR`, else `$NOVAFABRIC_HOME/capsules` | Where to look for capsules — the directory `nova capture` writes to. |
| `--db-path` | `$NOVAFABRIC_DB_PATH`, else `$NOVAFABRIC_HOME/registry.db` | Registry/lineage SQLite path; the startup panel prints the resolved path. |
| `--no-browser` | off | Don't auto-open a browser tab. |
| `--topology` | off | Enable the live topology dashboard (`/topology/`). |
| `--tv5` | off | Enable the experimental 3D topology view (implied by `--topology`). |
| `--topology-louvain-resolution` | `1.0` (or `NOVA_TOPOLOGY_LOUVAIN_RESOLUTION`) | Louvain clustering resolution. Lower values merge into fewer/larger clusters; higher values split further. |
| `--tls-cert` / `--tls-key` | off (or `NOVA_TLS_CERT_PATH` / `NOVA_TLS_KEY_PATH`) | Serve HTTPS directly (ADR-0241, first-party TLS). Both are required together; the key file must be mode 600. |

Stop with `Ctrl+C`. The session token is **not** rotated per start: it persists in
`$NOVAFABRIC_HOME/.serve-token` (or is pinned with `NOVAFABRIC_SERVE_TOKEN`) so a restart
keeps open browser sessions. Delete the file to rotate it (`serve/auth.py`).

### Topology view modes

The topology dashboard (`/topology/`) offers a **view-mode switcher** over five
complementary lenses on the same data — pick the right one for the task:

| Mode | What it shows | Best for |
|---|---|---|
| **Cluster** (default) | 2D force layout of cluster super-nodes, sized by agent count; click a cluster to expand its agents | Structure overview + drill-in |
| **Call-graph** | 2D layered (dagre) layout following the directed run → model → tool flow | Reading call direction/hierarchy |
| **Treemap** | Rectangles with area proportional to agent count | Spotting the biggest clusters at a glance |
| **Table** | Sortable, searchable exact list of clusters | Precise lookup; the anti-hairball |
| **3D (experimental)** | Three.js orbit view (TV-5); labels appear on hover/selection | Spatial exploration (not default) |

Graph modes provide on-screen **+ / − / Fit** zoom controls. Labels are
decluttered — only large, hovered, or selected nodes are labeled — so the
full-graph view stays legible at scale. Clusters whose only member is a single
agent (e.g. runs with no captured model calls) are collapsed into one "misc"
super-node rather than scattered as visual noise.

---

## Capability matrix: dashboard vs CLI

The dashboard exposes the **majority** of the read-side and many of the write-side capabilities of the CLI, but a number of operations are intentionally CLI-only. Every dashboard action surfaces its equivalent `nova` command in the confirm dialog.

> **Reading the "Runs tab → row *X* button" entries below.** Since **v0.98.2** the
> per-row action buttons are **revealed, not always visible**: they render for the
> **selected** row and on **hover or keyboard focus** of any row. (Ten always-visible
> buttons per row wrapped onto four lines and left only three runs on screen.) The
> actions are unchanged and never more than one interaction away — click or tab to the
> row, then click the action.

### Read operations (Layer A — fully covered)

| Capability | CLI | Dashboard | Notes |
|---|---|---|---|
| List capsules under `.novafabric/runs/` | `nova` *(no direct command — discovers them)* | ✅ Runs tab | Search by run_id or command, filter by status, sort by time/duration, opt-in 5s auto-refresh. **v0.12.9 (DU-1):** status filter pill-bar (All / running / success / failure / error) above the table. **v0.12.9 (DU-2):** hover-reveal copy icon on each run ID cell. |
| Inspect a single capsule | `nova inspect <run-id>` | ✅ Runs tab → click row | File-tree + YAML pane + expandable JSONL rows + in-browser Ajv validate. |
| Validate a capsule against its schema | `nova validate <capsule>` | ✅ Capsule view → "Validate" button | Same `run-capsule.schema.json` compiled in-browser via Ajv. |
| List registered assets | `nova list [--type] [--status]` | ✅ Registry tab | Filters not yet exposed in UI; visible via the live API. |
| Get a single asset | `nova inspect <name@version>` | ✅ Registry tab → click row | Eval results for the selected asset are lazy-fetched. |
| Lineage queries | `nova lineage provenance / blast-radius / replay-chain` | ✅ Lineage tab | Full DAG render via React Flow + dagre. Click (or double-click) a node to see incoming/outgoing edges. **v0.12.6:** interactive QueryPanel — type a ref, pick mode (provenance / blast-radius / replay-chain), hit Run; results appear as a sortable table; CLI equivalent preview updates live. **v0.12.7:** ref input autocompletes `name@version` (provenance/blast-radius) or `run_id` (replay-chain) from loaded data. **v0.12.9 (DU-3):** when a node is selected, a breadcrumb row above the graph shows the full ancestry chain (root → … → parent → **selected**); each ancestor is clickable. **v0.13.4:** `zoomOnDoubleClick` disabled; double-clicking a node selects it (identical to single-click) instead of triggering zoom-to-fit. |
| Cluster-scale infrastructure status | *(no single command — consult each Phase 0–6 component separately)* | ✅ Infra tab (v0.12.6) | 10 component cards: NovaSeal, Collector, Object Store, Metadata DB, Lineage at Scale, Parent/Child Capsule, Server Mode, Eval Suites, Policy Gates, Run Capsule — each with a `shipped / partial / placeholder / planned` status badge plus the relevant CLI commands. Lets operators confirm which Phase 0–6 components are active without leaving the dashboard. |
| CLI command reference with live preview | *(all `nova` sub-commands)* | ✅ Commands tab | **v0.8:** 13 command builders for core capture, registry, and replay. **v0.12.6:** expanded to 35 hand-curated builders across 4 journey tracks. **v0.60.0:** now mirrors the **complete** CLI — every `nova` command (374 today) has a fillable form. Hand-curated builders still provide richer copy for the most-used commands; the rest are auto-generated from the live Typer app (`ui/dashboard/scripts/gen-command-registry.py` → `generatedCommands.ts`), so the tab stays in sync as commands are added/removed (guarded by `tests/serve/test_command_registry_coverage.py`). Fields, choices, defaults and flags come from the CLI's own parameter metadata. Each builder renders a live command preview with a copy button; a filter box and per-group counts navigate the full surface. Copy-only (Layer C, ADR-0027) — the dashboard never executes commands. |
| Structural diff | `nova diff <run-a> <run-b>` | ✅ Diff tab | Aligned with `diff-report.schema.json`; renders environment / model_calls / tool_calls / outputs sections. **v0.9 additions:** 'Compare against…' in CapsuleInspector, word-level diff for model responses, mutation badge on tool call pairs. **v0.12 addition (C-4):** Multi-select checkboxes in RunsTab — check any 2 rows, click "Compare selected ⊕", jumps directly to Diff tab with both IDs pre-filled and diff auto-triggered; no copy-paste. See [ADR-0036](https://novafabric.ai/docs/decisions/). **v0.12.7:** both run A and run B inputs autocomplete from the loaded runs list. **v0.12.9 (DU-4):** last comparison (from/to/result) persisted in `sessionStorage` — navigating away and back restores the previous comparison without re-fetching. **v0.13.3 (C-5):** Checkbox cap lifted 2→5; selecting 3–5 runs triggers N-run mode — first run is the baseline, N-1 parallel diffs fire via `runMultiDiff`, results shown as stacked collapsible `MultiDiffCard` panels with change-count badges. URL format migrated from `?run_a=&run_b=` to `?run_ids=a,b,c` (old params still parsed for backward compat). |
| Read evidence bundle | *(no read-only command — open the ZIP)* | ✅ Evidence tab (v0.9) | Lists all bundles from the evidence directory (`$NOVAFABRIC_EVIDENCE_DIR`, else `$NOVAFABRIC_HOME/evidence/`), shows DSSE statement, ZIP download; the Verify button shows the server verifier's verdict (below). See [ADR-0037](https://novafabric.ai/docs/decisions/). |
| Verify evidence bundle cryptographic integrity | `nova verify <bundle>.zip` (`--public-key` to pin the signer) | ✅ Evidence tab → Verify button (v0.11) | `POST /api/evidence/{id}/verify` — the same verifier as `nova verify <bundle>.zip` (digests, `manifest_hash`, every DSSE signature, signed subjects) + RFC 3161 TSR + NovaSeal Merkle log inclusion. Inline `valid`/`invalid`, `content`, `signer`, `sig`, `tsr`, `log` badges. `signer unanchored` (neutral) when the server has no evidence trust list; `log –` when NovaSeal is not configured locally. The local log check opens configured logs read-only and never creates a Merkle log. |
| Validate a capsule's schema and file structure | `nova validate <capsule>` | ✅ Runs tab → Validate button (v0.11) | `POST /api/runs/{id}/validate` — checks required files, YAML parse, JSONL presence. Green `✓ valid` or red expandable error list. |
| View secret scan results | `nova scan-secrets <capsule>` | ✅ Runs tab → **Secrets** button on card (quick-access, v0.11) or → Secrets tab in detail panel | `GET /api/runs/{id}/redaction-proof` — shows scanner+packs, targets (hash before/after), per-finding severity badges. Includes re-scan trigger. |
| Report generation | `nova report [--format markdown\|json\|html\|pdf]` | ◐ Reports tab | The Reports tab has its own registry-driven builders with CSV/JSON download plus **self-contained HTML and PDF artifacts with embedded charts** (`GET /api/reports/{id}/export`, ADR-0201; PDF needs the optional WeasyPrint extra — the UI surfaces the 501 install hint). The CLI's *asset-inventory* report (`nova report`) additionally supports `--format html|pdf` with the same chart engine. |
| Forensics timeline | `nova forensics timeline <capsule>` | ✅ Runs tab → **Forensics** view (v0.64.0, P5) | `GET /api/runs/{id}/forensics-timeline` reconstructs a deterministic timeline from the run's own sealed capsule via the same `merge_timeline` core. Honest scope: run-lifecycle + model/tool-call events only; missing timestamps and the absent lineage collector are shown as *gaps*, never fabricated. |
| Cost attribution / fairness / usage-breakdown | `nova cost attribute` / `fairness` / `usage-breakdown` | ✅ Cost tab → tools panel (v0.64.0, P6) | Three pure POST endpoints (`/api/cost/{attribute,fairness,usage-breakdown}`) wrapping the same cores the CLI calls, given a document instead of a file path. Descriptive only — no cost verdict. |
| Backup-set status | `nova backup verify` *(listing has no direct command)* | ✅ Infra tab → **Backups** card (v0.64.0, P7) | `GET /api/infra/backups` lists `NOVA_BACKUP_DIR` archives from their `manifest.json` (manifest-claimed, **not** hash-verified — that stays `nova backup verify`). Degrades to `{detected:false}` when unconfigured. |
| Lineage time-travel queries | `nova lineage time-travel <ref> --asof <ts>` | ❌ — | Not yet wired into the dashboard; CLI-only in v0.7. |
| Lineage OpenLineage emission | `nova lineage emit-openlineage` | ❌ — | Integration target; CLI-only. |

### Write operations (Layer B — confirm-gated, audit-logged)

| Capability | CLI | Dashboard | Notes |
|---|---|---|---|
| Register an asset | `nova register <spec.yaml>` | ✅ Registry tab → "+ Register asset" | Paste YAML into the dialog; spec validated by the same `validate_spec` the CLI uses. |
| Run eval suites | `nova eval agent <name@version>` | ✅ Registry tab → row "EVAL" button | Runs the same `run_evals` entry point as the CLI. **v0.9 addition:** Eval history sparkline per asset (last 10 results, green/red bars) and full eval history panel in the eval dialog. See [ADR-0038](https://novafabric.ai/docs/decisions/). |
| Promote an asset | `nova promote direct <name@version> --to <status> [--force]` | ✅ Registry tab → row "PROMOTE →" button | Eval gate enforced for agents (use `--force` only with intent). Force is visually marked as destructive. **v0.12.9:** invalid target buttons disabled client-side (e.g. `archived → production` is dimmed); inline error banner shown on server rejection; eval-gate copy conditional on `asset_type === 'agent'`. **v0.13.0 (D-5):** `nova promote` is now a sub-group (`direct` / `propose` / `approve`). The dashboard Promote button maps to `direct`. For maker-checker SoD flow, use `nova promote propose` + `nova promote approve` from the CLI. **ADR-0312:** `nova promote direct --to production` is refused without an explicit `promote_direct` policy allow, and so is the dashboard Promote button: both HTTP promote endpoints call the same gate and answer 403; `force` does not bypass it. |
| Bulk-promote multiple assets | *(run `nova promote` for each)* | ✅ Registry tab → checkbox column + floating action bar (v0.12.9) | Select one or more assets, click "Promote N" in the floating bar; each is promoted to its next valid status in sequence. Select-all master checkbox in header. Floating bar appears only when ≥ 1 asset is checked. |
| Forensic replay | `nova replay <capsule> --mode forensic` | ✅ Runs tab → row "REPLAY" button | Read-only inspection, no subprocess, safe. |
| Semantic replay analysis | `nova replay <capsule> --mode semantic` | ✅ Runs tab → row "SEMANTIC" button (v0.11) | Compute pairwise text similarity across model call responses. Returns `similarity_score` (0–100%) and a gauge. Read-only, no subprocess. |
| Exact replay eligibility | `nova replay <capsule> --mode exact` | ✅ Runs tab → row "EXACT" button (v0.11) | Check whether the capsule satisfies exact replay requirements: `env.lock.lock_mode=deterministic` and `seed` present on every model call. Returns `exact_eligible` (bool) + blocker list. |
| Re-scan & redact | `nova redact <capsule>` | ✅ Runs tab → row "REDACT" button | Re-runs `SecretScannerV0` and overwrites `redaction-proof.json`. **Limitation:** strategy overrides and unsafe-skip marking (`--strategy-override`, `--mark-unsafe-skip`, `--rationale`) are CLI-only. |
| Maintenance recompute (safe) | *(reindex has no direct command; KG/topology mirror `nova kg ingest --all`)* | ✅ Infra tab → **Maintenance** card (v0.64.0, P8-P10) | Three idempotent, lossless, confirm-gated actions: reindex runs cache (`POST /api/admin/reindex-runs` — full INSERT-OR-REPLACE rebuild, an APIRouter module per the ADR-0183 route freeze), re-seed topology (`/api/topology/seed`), rebuild KG (`/api/kg/ingest-all`). None deletes user data. **v0.98.0:** reindex accepts optional `"prune": true` (default false) to drop index rows whose capsule directory no longer exists — dangling entries the additive rebuild cannot clear, which otherwise list in the Runs tab and 404 on every drill-in. Capsules are never touched; only the derived index row is removed. |
| Compare two asset spec versions | `nova diff <name@v1> <name@v2>` | ✅ Registry tab → "Compare…" (v0.11) | `GET /api/assets/{name}/diff?from_version=&to_version=` — flattened field diff; green `+`, red `−`, yellow `~` table rows. Available on assets with ≥ 2 registered versions. |
| Export signed evidence | `nova export-evidence <capsule> --output <zip> --key <pem>` | ✅ Runs tab → row "EXPORT ↗" button | Signs with the default evidence key, `$NOVAFABRIC_HOME/keys/signing_key.pem` — the key `nova init` writes (a pre-existing `keys/local-key.pem` from an earlier version is used when `signing_key.pem` is absent); auto-generated there if missing (audit-logged). Output defaults to `~/.novafabric/evidence/<run_id>.zip`. **Limitation:** custom output paths and `--allow-unsafe-skips` are wired in the API but not surfaced as form controls in the UI yet. |
| Place / release a legal hold | `nova hold create <registry> --reason … [--duration-days N]` / `nova hold release <hold-id>` | ✅ Holds tab (v0.11) | `POST /api/holds` + `POST /api/holds/{id}/release`. Active holds grouped by registry; inline release button; place-hold form with optional duration. **v0.12.7:** registry name input autocompletes from the list of registries shown on-screen. |
| List active legal holds | `nova hold list <registry>` | ✅ Holds tab (v0.11) | `GET /api/holds` — lists every registry in the hold store shared with `nova hold` (`$NOVAFABRIC_HOME/registries/`, plus holds still at the pre-unification `<capsule-dir>/../registries/` and `./.novafabric/registries/`); sidebar badge shows active count. A hold placed here blocks `nova retention` and `nova capsule delete` too. |
| Interactive policy check | *(no direct CLI equivalent — there is no `nova policy check`; the CLI surface is `nova policy test` for the Rego suite and `nova policy explain <decision-id>` for a past decision. Corrected 2026-08-01.)* | ✅ Policy tab (v0.11) | `POST /api/policy/check` — evaluates a `PolicyInput` (action, subject, resource) via the OPA engine and returns a `PolicyDecision`; large ALLOW/DENY badge + reason + metadata; yellow warning when OPA is not installed. **v0.12.7:** resource ref autocompletes `name@version` when kind = asset, or `run_id` when kind = capsule/replay; loads at mount from `GET /api/assets` and `GET /api/runs`. **v0.12.9 (DU-9):** Rego source textarea with 300 ms debounced client-side syntax check (missing `package`, unbalanced braces); advisory warning banner, does not block submission. **v0.13.2 (DC-5 Explain):** "explain" checkbox triggers `POST /api/policy/check` with `explain: true`; backend runs a second OPA subprocess with `--explain full --format pretty`; `trace_text` returned in `PolicyDecision`; decision result shows "show trace ↓" toggle that reveals a `max-h-64` scrollable monospace trace panel. **Scope:** checking the packaged policies needs `read`; submitting your own Rego source needs `admin` (403 otherwise), and every evaluation runs in a sandboxed `opa` (no server environment, no `opa.runtime`/`http.send`/`net.lookup_ip_addr`). |
| Export GDPR Art.30 RoPA entry | `nova export-ropa <capsule> --output ropa.json` | ✅ Compliance tab → **GDPR Art.30 RoPA Export** (v0.37.0) | `POST /api/compliance/export/ropa` — optional `controller_name` / `controller_contact`; completeness badge + missing-fields list + JSON document display. |
| Export AI-SBOM (CycloneDX 1.7) | `nova export-aibom <capsule> [--output aibom.json]` | ✅ Compliance tab → **AI-SBOM Export** (v0.37.0) | `POST /api/compliance/export/aibom` — component list (type/name/version/description), `serial_number`, `bom_format`. CycloneDX ML-BOM 1.7 (ECMA-424 2nd Edition) since v0.39.0. |
| Export NIST AI RMF report | `nova export-nist-rmf <capsule> --output report.json` | ✅ Compliance tab → **NIST AI RMF Report** (v0.37.0) | `POST /api/compliance/export/nist-rmf` — GOVERN/MAP/MEASURE/MANAGE score bars, risk-level badge, missing-evidence list. |
| Check AIBOM coverage (CRA) | `nova aibom status [--capsules-dir <dir>]` | ✅ Compliance tab → **AI-SBOM Coverage Status** (v0.37.0) | `GET /api/aibom/status` — auto-loads on mount; total/covered/missing capsule counts, coverage progress bar, CRA deadline 2026-09-11. |
| Execute GDPR Art.17 erasure | `nova pii erase <subject_id>` | ✅ Compliance tab → **GDPR Erasure Request** (**experimental**, ADR-0210) | `POST /api/compliance/erasure/request` — **real DEK destruction** (experimental — makes PII unreadable only in capsules captured with `--pii-subject`, ADR-0314; replaced the v0.18.0 no-op stub): persists the request in `$NOVAFABRIC_HOME/erasure.db`, then destroys the subject's AES-256-GCM DEK through the same `DEKStore.erase_subject` code path as the CLI. Safe-mutations gated — body must carry `confirmed: true` (400 otherwise, nothing mutated). Terminal states `COMPLETED` (receipt + `receipt_sha256`), `DEFERRED` (Art.17(3)(b) retention window, `earliest_erasure_at`), `HELD` (an active legal hold kept the DEK, GDPR Art.17(3)(e) → 409 `legal_hold_active`), `FAILED` (error receipt; unknown subject → `subject_not_found`). Duplicate request while one is PENDING re-attaches to it (idempotency + crash recovery). Both audit surfaces record the action hash-only (`subject_sha256`, never the raw subject). Gated only on crypto-shredding's own prerequisite (the DEK store loads; 409 `crypto_shred_unavailable` otherwise) — `NOVA_CAP003_ENABLED`, which governs the separate dual-object split, does not gate it. `GET /api/compliance/erasure/status` lists the persisted queue. The `/v0` server-mode mirror is **planned** (ADR-0210 D7), not implemented. |
| Mocked replay (re-execute agent code, cache hits) | `nova replay <capsule> --mode mocked` | ❌ — | Mocked replay spawns the agent subprocess. That straddles Layer B/C; in v0.7 it is **CLI-only** until the sandbox model lands per ADR-0027. |
| Scan secrets | `nova scan-secrets <capsule>` | ❌ — | Use `redact` from the dashboard, or `nova scan-secrets` from the terminal. |

### Capture and orchestration (Layer C — deferred)

| Capability | CLI | Dashboard | Notes |
|---|---|---|---|
| Capture a command | `nova capture <command>` | ❌ Deferred to v0.9 | Per ADR-0027 §1 Layer C, launching arbitrary subprocesses from a browser endpoint requires a sandboxed exec model, per-action audit log entries with full command text, and a confirmation flow that shows the command verbatim. None of those primitives are committed yet. **Layer C may not ship at all** if the security review concludes the risk outweighs the value. The Capture tab in the dashboard documents this and lists the four CLI alternatives (`nova capture`, `--runner docker`, `nova mcp-proxy`, `nova api-proxy`). |
| MCP proxy | `nova mcp-proxy <upstream>` | ❌ — | Long-running stdio bridge; CLI-only. |
| LLM API proxy | `nova api-proxy --upstream-url <url>` | ❌ — | Long-running HTTP server; CLI-only. |
| Capture with custom runner | `nova capture --runner {docker,kubernetes,slurm} ...` | ❌ — | Same Layer C reasoning. |

### Operational / advanced (CLI-only by design)

These are intentionally not in the dashboard because the CLI is the right surface for them:

| Capability | CLI | Why not in the dashboard |
|---|---|---|
| Schema validation of arbitrary files | `nova validate <spec.yaml>` (asset specs) | Use the dashboard's Register dialog for assets; pipe through `nova validate` for batch jobs. |
| Asset diff between two versions | `nova diff <name@v1> <name@v2>` | ✅ Registry tab → Compare (v0.11) — moved to the [Write operations](#write-operations-layer-b--confirm-gated-audit-logged) table above. |
| Streaming real-time output during a long-running job | (the capture command itself) | Dashboards aren't the right surface for tailing a single subprocess; use `nova capture` directly. |
| CI / non-interactive scripted runs | All commands | Dashboards require a browser; CI uses the CLI. |
| Cluster runners (Docker / K8s / Slurm) | `nova capture --runner ...` | Driven from the orchestration host, not from a browser tab. |

---

## What the dashboard does that the CLI cannot

A short list, for completeness — the dashboard is not strictly a subset:

- **Live filtered runs view.** Search across `run_id` and command, filter by status, sort by time or duration. The CLI prints a single shell-friendly stream; the dashboard makes 200+ capsules navigable.
- **Interactive lineage DAG.** React Flow + dagre layout, three highlight modes (provenance / blast-radius / replay-chain) without re-querying. CLI returns lineage as JSON; the dashboard visualises the graph.
- **In-browser schema validation.** The Validate button compiles `run-capsule.schema.json` via Ajv in the browser and reports timing. Useful as a sanity check before sharing a capsule.
- **In-browser ed25519 verification — showcase page only.** The static `/showcase/evidence` demo checks a bundled fixture with `SubtleCrypto.verify('Ed25519', …)` and has a tamper toggle that flips a byte. The live Evidence tab no longer verifies in the browser: a single DSSE signature check there ignored every member digest, so its Verify button shows the server verifier's verdict instead.
- **Server-side bundle verification (v0.11).** `POST /api/evidence/{id}/verify` runs the same verifier as `nova verify <bundle>.zip` (artifact digests, `manifest_hash`, every attestation's DSSE signature, signed subjects recomputed from the bundle's content), plus the RFC 3161 TSR and NovaSeal Merkle log inclusion, and returns structured per-check results with `signer_trust`. To pin the signer offline, run `nova verify <bundle>.zip --public-key <pem>`.
- **Single-pane audit log.** Every dashboard mutation appends to `~/.novafabric/dashboard-audit.jsonl`; the Audit tab shows recent entries newest-first with the equivalent CLI command. The CLI does not have an equivalent unified audit log for its own invocations (each command logs its own outputs). **v0.12.9 (DU-6):** action-type filter dropdown — derived dynamically from loaded entries — narrows the list to a single action type (promote / eval / rollback / approve / register / …).
- **Context-aware autocomplete on all ref inputs (v0.12.7).** Every text input that accepts a database reference — lineage query ref, diff run A/B, holds registry name, policy resource ref — shows a live-filtered dropdown populated from data already on the page. Focus the field to browse the top eight items; type to filter. The CLI has shell-level completion (Tab) but no live database-backed suggestions.
- **Staleness indicator on Home tab resume cards (v0.12.9, DU-7).** Resume cards older than 24 hours show an amber border and a "Last updated X — may be stale" tooltip. Useful for detecting runs that started but never completed.
- **Open capsule folder from Capture tab (v0.12.9, DU-8).** The Capture tab's "Recent capsules" panel lists the 5 most recent runs; when `capsule_path` is present, an **Open folder** link opens the local directory in the system file manager via the `file://` protocol.

---

## Security model

- **Localhost only** by default. `--host 0.0.0.0` is rejected without `--insecure`. With `--insecure`, serve over TLS: either first-party (`--tls-cert` / `--tls-key`, or `NOVA_TLS_CERT_PATH` / `NOVA_TLS_KEY_PATH`; ADR-0241) or behind a TLS-terminating reverse proxy.
- **Token authentication.** A cryptographically random URL-safe token (`secrets.token_urlsafe(32)`) is generated at start-up and written to `$NOVAFABRIC_HOME/.serve-token` (mode 0600); the start-up panel prints the resolved path. It is **not** rotated on every restart — an existing token file is reused so a restart does not invalidate open browser sessions, and the token therefore survives on disk after the server exits until something deletes it. The token must be passed on every `/api/*` request, either as an `Authorization: Bearer …` header or as `?token=…`. When both are present the `Bearer` header is authoritative. (Bearer support was added in v0.97.0; this paragraph previously stated that Bearer headers were rejected, which has not been accurate since.)
- **Browser security headers.** Every response carries `Content-Security-Policy: object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` — the last because the token may travel in `?token=` and must not leak to a linked site through the `Referer` header. Capsule-derived text (run labels, commands) is rendered as text, never as markup. `script-src` is not restricted yet (the built dashboard has an inline hydration script).
- **Scope-based authorization** *(experimental, v0.102.0, ADR-0228).* Authentication alone used to grant every endpoint: one token, and `DELETE /api/runs/{run_id}`, `POST /api/compliance/pii/erase` and `POST /api/admin/roles` were all reachable with it. Each route now carries one of four scopes and a credential is checked against it:

  | Scope | Covers | ADR-0027 layer |
  |---|---|---|
  | `read` | every read-only endpoint — runs, lineage, evidence, compliance reads, reports, exports, `/metrics`, and the Policy tab's check against the **packaged** policies | Layer A |
  | `operate` | bounded, reversible mutations — register, promote, score, placing a hold, incident, ingest | Layer B |
  | `admin` | irreversible or privilege-affecting — capsule/run deletion, releasing a legal hold, PII erase, seal bypass, signing, key ratchet, role and token management, and the `forensic` / `semantic` / `exact` replay routes (`POST /api/runs/{run_id}/replay/{mode}`; none of them re-executes the workload — `exact` only checks eligibility — but they are classified `admin`, while `replay/dry-run` is `operate`) | Layer C |
  | `audit` | everything `read` covers **plus** the audit trail and recorded policy decisions (including `GET /api/policy/explain` and the `/export` of the audit reports) — and **never** `operate` or `admin`, at any level | orthogonal |

  `admin` ⊇ `operate` ⊇ `read`, and `admin` also reaches `audit` (the same super-role rule `nova server`'s RBAC already applies). `audit` sits **outside** the chain on purpose: the persona must see more evidence than an ordinary reader — including who did what — while being structurally incapable of changing anything.

  **Nothing changes on a laptop.** The `.serve-token` holds `admin`, and so does any token issued before this landed, so a single-user `nova serve` behaves exactly as it did. Authorization only bites once you deliberately mint a narrower credential:

  ```bash
  curl -X POST "http://127.0.0.1:4321/api/admin/tokens?token=$(cat "${NOVAFABRIC_HOME:-$HOME/.novafabric}/.serve-token")" \
       -H 'content-type: application/json' \
       -d '{"label": "auditor-laptop", "confirmed": true, "scope": "audit"}'
  ```

  The response carries the token **once** and reports the scope it minted; `GET /api/admin/tokens` lists the scope of every issued credential. Omit `scope` and you get `admin`, i.e. the previous behaviour. The scope must be exactly `read`, `operate`, `admin` or `audit`: a typo such as `admn` or `Admin` is refused with 422 and nothing is stored. If a stored record's `scope` is ever not one of those (for example after a hand edit of `tokens.jsonl`), that token holds **no** scope and is refused with 403 everywhere except the public probes. It is listed as `none`. It never falls back to `admin`. Only a record with no `scope` field at all, which predates scopes, reads as `admin`.

  **An export is never weaker than the report it exports.** `GET /api/reports/{report_id}/export` takes the scope of the report itself: a `read` token that is refused `dashboard-audit` is refused its HTML/PDF export too.

  **Your own Rego needs `admin`.** The Policy tab's Rego textarea (`policy_source`) and a custom `bundle_path` for the Rego test suite are refused with 403 to `read`, `operate` and `audit` tokens; they can still check and test the packaged policies. Whatever the scope, `opa` runs sandboxed: no server environment (so no `NOVAFABRIC_SERVE_TOKEN`), and no `opa.runtime`, `http.send` or `net.lookup_ip_addr`. A policy that calls one of them is a DENY whose reason names the undefined function.

  **A route with no classification is denied to everyone**, including the server token and a caller that sends no credential. That is deliberate: a forgotten classification then breaks a feature loudly instead of disclosing evidence quietly. A CI guard fails on the first unclassified route, so you should never meet this in a release.

  **Not yet:** roles assigned through `POST /api/admin/roles` are *not* read back as scopes — that surface is now `admin`-gated, which is what closes the cross-mode escalation, but per-request consultation of `role_assignments` arrives with tenant scoping (ADR-0229). WebSocket subscriptions (`/api/tv5/ws`, `/topology/stream`) still check host and token only, not scope.

- **Tenancy posture** *(experimental, ADR-0229).* Authorization says what a credential may do; tenancy says whose data it may see. `nova serve` reads **eight** stores and they do not share a tenancy model:

  | Store | Class | Why |
  |---|---|---|
  | metadata store · evidence fabric · cost store · object capsule store | **aware** | each carries a real tenant discriminator — RLS + `begin_tenant_context()`, a `tenant` column, a `tenant_id` column, and the `capsules/<tenant>/…` object key respectively |
  | capsule directory | **agnostic** | isolation is supplied by the deployment via `--capsule-dir`, not by the store |
  | runs index · knowledge graph · lineage store | **unsafe** | no tenant discriminator at all, so they cannot answer "show me only tenant X" |

  Because three stores are unsafe — including the runs index behind `/api/runs` — **multi-tenant mode is refused rather than served**. Setting `NOVAFABRIC_SERVE_TENANCY=multi` makes `nova serve` exit before binding a socket, naming every blocking store and why:

  ```
  NOVAFABRIC_SERVE_TENANCY=multi was requested, but 3 store(s) in the serve read
  path cannot answer a tenant-scoped question:
    - knowledge_graph: KuzuDB has no RLS analogue and the graph schema carries no
      tenant property; a tenancy model for it needs its own ADR (ADR-0229 OQ-2)
    - lineage_store: the SQLite lineage schema carries no tenant column, …
    - runs_cache: the runs index has no tenant column, so /api/runs and every list
      built on it would return rows from every tenant (ADR-0229 OQ-1)
  ```

  This is deliberately blunter than ADR-0229 D2's per-endpoint 503: refusing only the KG and lineage panels would keep serving `/api/runs` unscoped, and an operator watching some panels refuse would reasonably conclude the rest were scoped. **For multi-tenant deployments today, use `nova server`** — the multi-user REST API, which enforces tenancy at the metadata store.

  `GET /api/doctor` reports the posture in **both** modes under the `tenancy_posture` check. Single-tenant is a correct posture, so the check reports `ok`; read the `blocking` list, not the flag.

- **Server-side paths are confined** *(works today)*. Several actions take a server-side path in the request body: the evidence export's output and signing key, capsule migration's source and output, policy signing's key, cert and DB, the KG and lineage `capsule_path`, the policy-test bundle, and the metadata rebuild target. Each path is resolved (`~` expanded, symlinks followed) and must land inside an allowlisted root. **Writes** go only to `$NOVAFABRIC_HOME/exports`, `$NOVAFABRIC_HOME/evidence` or `$NOVAFABRIC_EVIDENCE_DIR`. **Reads** may also use `$NOVAFABRIC_HOME` and the server's capsule directory. A `..` segment or a path outside the roots gets 403. Reads never reach the server's own secrets: the token stores, `.env`, `dek.db`, the ratchet state, `config.yaml`, the audit logs, or (for a source path) key material and `*.pem`/`*.key` files; a directory containing one, such as `$NOVAFABRIC_HOME` itself, is refused too (403). A relative path is taken from `$NOVAFABRIC_HOME` (reads) or `$NOVAFABRIC_HOME/exports` (writes). To use another directory, for example a mounted volume, list it in `NOVAFABRIC_SERVE_PATH_ROOTS` (absolute paths, separated by `:` on Linux and macOS) before starting `nova serve`. The former denylist and its `NOVAFABRIC_SERVE_ALLOW_ANY_PATH` opt-out are gone; `nova serve` warns if that variable is still set.
- **Dashboard promotion follows the CLI gate** *(works today, ADR-0312)*. The Registry tab's Promote button is a single-actor `nova promote direct`, and it is gated the same way: `production` is refused (403) unless an OPA policy explicitly allows the `promote_direct` action, and **force** does not bypass that. Use `nova promote propose` + `nova promote approve` to reach production.
- **DNS-rebinding defence.** `Host` header must be `127.0.0.1`, `localhost`, or `::1`. Other hosts return 403 even with a valid token.
- **CORS allowlist.** Only `http://localhost:4321`, `http://127.0.0.1:4321`, `http://localhost:4322` and `http://127.0.0.1:4322` are accepted (methods `GET`, `POST`, `DELETE`). Defence in depth — the token is the authoritative gate.
- **Constant-time token compare.** `_consteq` compares bytes equally regardless of where the mismatch is.
- **Server-side state is plain files, shared with the CLI.** Stopping the server leaves behind what it persisted: under `$NOVAFABRIC_HOME`, the serve token (`.serve-token`), issued tokens (`tokens.jsonl`), the dashboard audit log (`dashboard-audit.jsonl`), the GDPR erasure queue (`erasure.db`) and dashboards (`dashboards/`, as `nova dashboard`); saved views in `$NOVAFABRIC_VIEWS_DIR`, else `./.novafabric/views` under the server's working directory (as `nova view`); and any artifacts the user created (evidence bundles, exports). Registry and lineage live in the same `registry.db` the CLI uses; the dashboard keeps no database of its own beyond `erasure.db`.

---

## Audit log

Every Layer B mutation appends a JSONL record to `~/.novafabric/dashboard-audit.jsonl` (override with `NOVAFABRIC_DASHBOARD_AUDIT_FILE`). Each record has:

- `audit_id` — UUID
- `ts` — ISO 8601 timestamp
- `action` — e.g. `register_asset`, `eval_asset`, `promote_asset`, `forensic_replay`, `redact`, `export_evidence`
- `args` — the action-specific arguments (no secrets — the YAML body of `register_asset` is recorded only as a length, not the content)
- `cli_equivalent` — the `nova …` command a human could re-run
- `actor_token_fp` — the acting subject. On action records it is a fingerprint of the credential: for the server token, 12 hex characters of a domain-separated SHA-256 (stable, and never a prefix of the token); for an issued token, the start of its listed fingerprint. On `authz.denied` records it is `local` for the server token, the issued token's fingerprint, or `anonymous`. Never the token itself, nor any part of it. Records written before this fix carried the token's first 8 characters; rotate the serve token if those records left the machine
- `result` — `ok`, `error`, or `denied`
- `error` — present only on failures

An authorization failure is itself an audited event: a **403** appends an `authz.denied` record with top-level `required_scope`, `held_scope` and `resource` (ADR-0231 D4 — a SIEM rule that has to reach into a free-form payload to find them breaks the next time that payload changes). A **401** appends nothing — a 401 is usually a stale browser tab, and recording the credential that produced it would put the bearer token back in a log that v0.98.0 took it out of.

Records also carry, since ADR-0231:

- `actor` — `{"identity_source": "shared-token" | "credential" | "federated", "id"?: …}`. **The log states how well it knows the actor rather than implying it knows.** With the shared `.serve-token` every record carries the same fingerprint, so `identity_source` is `shared-token` and `id` is **absent** — not null. An ADR-0228 issued token is `credential`.
- `resource`, `prior`, `current` — before-and-after state for the mutation, captured **at the mutation site**. A re-read afterwards would race concurrent writers and record a transition that never happened. Supplied today by `assign_role` and `revoke_role`; the remaining handlers are deferred and enumerated in ADR-0231.
- `redaction` — `{applied, ruleset, findings}`, proof that captured state passed the ADR-0187 ruleset. This log is append-only and deliberately unrotated, so unredacted state would sit on disk permanently.

**Exporting to a SIEM is unchanged** (ADR-0191): `nova audit-log export --source dashboard --format ocsf|cef|jsonl`. ADR-0231 added no format and no sink; the same records simply carry more.

⚠ In the OCSF projection a token fingerprint appears as `actor.user.uid` with `user.type_id: 0` (Unknown) and `actor.nova_identity_source`, **never** as `actor.user.name`. It used to be `user.name`, which a SIEM renders in a column headed *User* — reading as a person when it is one shared session credential.
- `extra` — action-specific metadata (e.g. `asset_id`, `replay_id`, `size_bytes`, `key_autogenerated_at`)

The file is append-only with mode 0600. To read it: `cat ~/.novafabric/dashboard-audit.jsonl | jq` or use the dashboard's **Audit** tab.

---

## Trace viewer (v0.7.1)

The **Trace** view (click a run row → "Trace" button) shows three sub-tabs:

### Waterfall tab

Renders the `trace.jsonl` span tree as an indented Gantt chart.

- **Span tree** — indented rows; collapse/expand branches by clicking the arrow.
- **Agent label column** — shows the `nova.agent.id` of the span's owning agent
  (or the nearest non-root ancestor name when the attribute is absent).
- **Outcome coloring** — bar colour encodes outcome: green = success/ok, red = any
  error status. Span type is readable from the span name, not the bar colour.
- **Wall-clock concurrency** — each span's bar starts at its true wall-clock offset;
  concurrent spans overlap visually as they did at runtime.
- **Timeline ruler** — 6-tick ruler across the full run duration.

### Thread tab

Chronological sequence of every model call and tool call, styled as a conversation.

- **Agent badge** — coloured pill showing which agent issued the call. When
  `nova.agent.id` is absent the dashboard infers the agent name from the model
  call's system prompt (regex: `"You are the X"` → `x`; see ADR-0035).
- **Agent group dividers** — a coloured separator appears whenever the active agent
  changes, giving visual phase-boundary annotations without requiring `nova.phase`.
- **Outcome avatar border** — the avatar circle border is green for success, red
  for any error status.
- **LLM call detail** — model name, system (openai / anthropic / ollama / …),
  separate input↑ / output↓ token counts, cache-hit indicator, finish reason badge
  (stop / tool_calls / length / …).
- **Expandable rows** — click any event to expand its full arguments (tool) or last
  message + completion text (model). Collapsed: shows first 200 chars inline.
- **Timestamp** — ISO wall-clock time shown above each event (HH:MM:SS.mmm).

### Swimlane tab

Gantt chart with **one horizontal lane per agent** — designed to make parallel
evidence-gathering phases visible at a glance.

- **Lane per agent** — inferred or explicit via `nova.agent.id`.
- **Bar per call** — model calls and tool calls appear as bars; width encodes
  duration, x-position encodes wall-clock start offset.
- **Outcome colours** — green = success, red = error/failure. Hover for details.
- **Graceful degradation** — when no agent attribution is available, all calls
  appear in a single "root" lane with a hint linking to ADR-0035.

### Adding agent attribution to your agent code

```python
# When writing a model call record, add nova.agent.id:
capsule.append_model_call({
    "model_call_id": ...,
    "nova.agent.id": "kubernetes_sentinel",
    "nova.agent.type": "evidence",
    # ... rest of gen_ai.* fields
})

# Same for tool calls:
capsule.append_tool_call({
    "tool_call_id": ...,
    "nova.agent.id": "kubernetes_sentinel",
    # ... rest of fields
})
```

When `nova.agent.id` is present the swimlane and Thread views use it directly.
When it is absent the Thread view falls back to system-prompt inference; the
swimlane shows a single root lane.

---

## Evidence Tab (v0.9 + v0.11)

See [ADR-0037](https://novafabric.ai/docs/decisions/).

The Evidence Tab is a fully operational view over real bundles on disk. It requires `nova export-evidence` to have been run at least once (bundles live in `$NOVAFABRIC_EVIDENCE_DIR`, else `$NOVAFABRIC_HOME/evidence/`).

**Bundle list view** — table of all bundles found in the evidence directory, showing `run_id`, timestamp, file size, and a manifest-hash integrity badge. The badge is a shallow check (SHA-256 of `manifest.json` fields) that runs at list time; use the Verify button for cryptographic assurance.

**Detail pane** — selecting a bundle opens a detail pane showing the DSSE statement (pretty-printed JSON), attestation hashes for each artifact, and the signing key fingerprint (first 16 hex chars of the SHA-256 of the public key).

**Verify button (v0.11)** — calls `POST /api/evidence/{bundle_id}/verify` server-side. The route runs the same verifier as `nova verify <bundle>.zip` (`evidence/bundle_verify.py`), so the dashboard and the CLI reach the same verdict on the same bundle. The badges:

| Badge | What is verified | When neutral (`–`) |
|---|---|---|
| `valid` / `invalid` | The overall verdict: every check below that ran passed. Hover for the list of failures | Never |
| `content ✓ / ✗` | Every artifact digest, no missing, extra or duplicate member, `manifest_hash`, and each signed subject recomputed from the bundle's own `run-capsule/` copy and lineage `edges.jsonl` (`manifest.json` itself is not signed) | Against a server older than this check |
| `signer ✓ / ✗ / unanchored` | Whether every attestation was signed by a key in the operator's evidence trust list | `signer unanchored`: the server has no trust list, so the signatures verify only under the key shipped in the bundle. That proves the bundle is consistent, **not who signed it**; hover for the signer's key fingerprint |
| `sig ✓ / ✗` | Every listed attestation is signed and its DSSE signature verifies (with the detached `.sig`) | Never — always attempted |
| `tsr ✓ / ✗ / –` | RFC 3161 TSR in `manifest.dsse.tsr` against the DSSE bytes | When timestamping was not requested at export time |
| `log ✓ / ✗ / –` | NovaSeal Merkle log inclusion for the capsule with this run id on the server | When `novaseal.yaml` is not configured or the capsule dir is not locally available |

A bundle is `valid` when all non-null checks pass. `valid` with `signer unanchored` means "unchanged since signed by whoever holds the key in the bundle": anyone can re-sign an edited bundle with their own key and reach that state.

**Pinning the signer.** Put the Ed25519 public keys you trust, in PEM (the format `nova verify --public-key` takes; several blocks may be concatenated), in `$NOVAFABRIC_HOME/evidence-trusted-keys.pem`, or point `NOVAFABRIC_EVIDENCE_TRUSTED_KEYS` (in `nova serve`'s environment) at such a file. A bundle signed by any other key then reads `signer untrusted` and `invalid`. A key sent in the request is never used. A configured trust list that is missing, empty, unreadable or holds a non-Ed25519 key makes the route return 500: a broken anchor never silently becomes "unanchored". The file is read on every request, so editing it takes effect without a restart. A bundle above 1 GiB uncompressed is refused with 413; run `nova verify` on it instead.

**Download link** — direct link to download the raw ZIP for offline verification (`openssl ts -verify`) or sharing.

---

## Cross-run comparison (v0.9)

**Works today** (shipped v0.9; see [ADR-0036](https://novafabric.ai/docs/decisions/)) — the compare-selected flow below is live in the Runs tab.

The current diff workflow has six friction steps: find run A in the Runs tab, copy its `run_id`, navigate to the Diff tab, paste it, find run B, copy and paste its `run_id`. The v0.9 additions remove the copy-paste steps entirely.

**D2 — Two-click compare from RunsTab.** Select any two rows in the Runs tab (checkbox or Shift-click) and a "Compare" button appears in the toolbar. Clicking it navigates directly to the Diff tab with both `run_id` values pre-filled and the diff auto-triggered.

**D3 — Compare against… from CapsuleInspector.** A "Compare against…" dropdown inside the CapsuleInspector lets you pick a second run from a recency-sorted list, then jumps to the Diff tab pre-filled.

**D4 — Word-level LCS diff for model responses.** Model call response text is diffed at the word level using a Longest Common Subsequence algorithm, with insertions highlighted green and deletions struck through in red — making prompt-sensitivity changes legible at a glance.

**D5 — Mutation badge on tool call pairs.** When a matched tool call pair has different arguments or outputs, a ⚡ mutation badge appears on the pair header. Badge tooltip shows argument key(s) that changed.

---

## Infrastructure tab (v0.12.6)

The **Infra** sidebar entry (under the Platform group) shows the operational status of all ten Phase 0–6 cluster-scale components as card tiles:

| Component | Status | CLI to verify |
|---|---|---|
| Run Capsule (Phase 0 / v0.1) | `shipped` | `nova inspect <run-id>` |
| NovaSeal v0.1 (Phase 0) | `shipped` | `nova verify <capsule>` |
| Event Envelope v1 (Phase 1) | `shipped` | `nova validate <capsule>` |
| Collector tier (Phase 2) | `shipped` | `nova collector rebuild` (Python side) · `novafabric-collector --config <yaml>` (Go gateway, after `go build`) |
| Parent/Child Capsule (Phase 3) | `shipped` | `nova run show <id> --with-children` |
| Object Capsule Store (Phase 4) | `shipped` | `nova storage validate` / `nova storage inspect` |
| Metadata DB (Phase 5) | `shipped` | `nova db upgrade` |
| Lineage at Scale (Phase 6) | `shipped` | `nova lineage provenance <ref>` |
| Server Mode (v0.7) | `shipped` | `nova server start` |
| Eval Suites (v0.9) | `shipped` | `nova eval run <capsule>` |

Badge colours: **shipped** (green), **partial** (accent), **placeholder** (amber), **planned** (muted). Each card shows the CLI command(s) for that component so operators can investigate from the terminal without navigating away.

> **Corrected 2026-08-01.** This table previously named three commands that do not
> exist: `nova store status` (the group is `nova storage`, exposing `validate` and
> `inspect`), `nova db status` (`nova db` exposes `upgrade` and `migrate-to-postgres`
> only), and `nova eval <name@version>` (`nova eval` is a command group; the runner is
> `nova eval run`). The Collector row was also ambiguous: `novafabric-collector` is the
> **Go** gateway binary (source at `collector/cmd/novafabric-collector`, needs
> `go build` first — it is not a Python console script and is not on `PATH` after a
> `pip`/`uv` install); the Python-side surface is `nova collector rebuild`. All ten
> component statuses themselves are unchanged and correct — only the verification
> commands were wrong.

---

## Eval trend chart (v0.9)

**Works today** (shipped v0.9; see [ADR-0038](https://novafabric.ai/docs/decisions/)) — the sparkline + history panel below are live in the Registry tab.

**Per-asset eval sparkline.** Every row in the Registry tab gains an 8×32px inline SVG bar chart showing the last 10 eval results for that asset — green bars for pass, red for fail. The sparkline is lazy-loaded via `IntersectionObserver` so it does not block initial table render.

**Eval history panel.** Clicking the "EVAL" button for an asset opens an expanded history panel (above the trigger form) showing the last 20 eval results in a table: suite name, score, pass/fail, and relative timestamp (e.g. "3 days ago"). The panel is fetched on demand and cached for the session.

**v0.12.8 display fixes:**
- **Null score** — suites that produce only a pass/fail signal (no numeric score) store `{"score": null}`. Previously rendered as `0.00`; now renders as a muted `—` so it is not confused with a zero-score failure.
- **Empty suite name** — if `suite_name` is blank or absent in the database, the eval row shows italic `(unknown suite)` instead of empty whitespace.

---

## UX conveniences (v0.7)

- **Theme toggle** in the header — light / dark / system. Persisted in `localStorage`. An inline `<script>` runs before hydration to prevent flash.
- **Token-in-URL flow.** Click the URL `nova serve` prints (it embeds the token); the SPA consumes it, saves to `localStorage`, and strips it from the visible URL so it doesn't sit in browser history.
- **Auto-bounce on 401.** Any in-flight request that returns 401 (e.g. server restart, token rotation) clears the cached token and shows the connect screen again.
- **Confirm dialogs for every mutation.** Each shows a one-line description, the action's arguments where editable (e.g. promote target dropdown), the equivalent CLI command, and the audit-log notice.
- **Toast notifications** for success and failure of mutations — visible bottom-right for ~4 seconds.
- **Auto-refresh on the runs tab.** Opt-in 5-second polling; off by default.

---

## Roadmap

- **v0.7** *(shipped)* — Layer A (read-only) + Layer B partial (register, eval, promote, forensic replay, redact, export evidence). All CLI-equivalent actions are confirm-gated and audit-logged.
- **v0.8** *(shipped 2026-05-12)* — Home tab (journey cards, status bar, resume session), Commands tab (11 CLI command builders with live preview + copy), sidebar reorganized into 5 journey groups.
- **v0.9** *(shipped 2026-05-12)* — Evidence Tab native (list + verify real bundles), cross-run comparison UX (2-click compare shortcut, word-level diff, mutation badges), eval trend sparklines in Registry. See [ADR-0037](https://novafabric.ai/docs/decisions/), [ADR-0036](https://novafabric.ai/docs/decisions/), [ADR-0038](https://novafabric.ai/docs/decisions/).
- **v0.11** *(shipped 2026-05-14)* — Dashboard Completeness: all eight gap-closing tracks shipped.
  - **DC-8** — Diff compare URL persistence (`?run_a=&run_b=`).
  - **DC-3** — Secret scan results viewer (Secrets view in RunsTab, `GET /api/runs/{id}/redaction-proof`).
  - **DC-1** — Evidence verification UI (`POST /api/evidence/{id}/verify`, three-stage DSSE + TSR + Merkle check, inline badges).
  - **DC-7** — Capsule validation UI (`POST /api/runs/{id}/validate`, expandable error list).
  - **DC-6** — Asset spec diff (`GET /api/assets/{name}/diff`, Registry tab Compare… modal).
  - **DC-2** — Legal holds tab (place/release holds, sidebar badge count).
  - **DC-4** — Semantic + exact replay UI (similarity gauge, eligibility card).
  - **DC-5** — Policy check tab (`POST /api/policy/check`, interactive OPA/Rego policy tester, ALLOW/DENY badge).
- **v0.12** *(shipped 2026-05-14)* — C-4 compare shortcut in RunsTab: multi-select checkboxes (max 2), "Compare selected ⊕" banner, auto-jump to DiffTab with both IDs pre-filled and diff auto-triggered. `nova unregister` command added.
- **v0.12.6** *(shipped 2026-05-14)* — Dashboard coverage Phase 1: expanded Commands tab (13 → 35 builders across 4 journey tracks), Lineage QueryPanel (interactive provenance/blast-radius/replay-chain query with live CLI preview), InfraTab (10 Phase 0–6 component status cards), enriched CaptureTab (all 4 runners, distributed-run commands, full capture matrix). DD-1..DD-8 implementation plans created.
- **v0.12.7** *(shipped 2026-05-14)* — Context-aware autocomplete in every ref input: Lineage QueryPanel, Diff run A/B, Holds registry name, Policy resource ref. Shared `SuggestInput` component. Full documentation update.
- **v0.12.8** *(shipped 2026-05-14)* — Eval results panel: null score → `—`, empty suite name → `(unknown suite)`. Makefile `bundle` + `serve-local` targets.
- **v0.12.9** *(shipped 2026-05-14)* — Three promote-dialog bug fixes (Bug-1/2/3: valid transition enforcement, inline error banner, conditional eval-gate copy) and nine UX improvements: DU-1 (RunsTab status filter), DU-2 (copy run ID), DU-3 (lineage ancestry breadcrumb), DU-4 (DiffTab sessionStorage), DU-5 (bulk promote), DU-6 (AuditTab action filter), DU-7 (HomeTab staleness indicator), DU-8 (CaptureTab folder link), DU-9 (PolicyTab Rego lint).
- **v0.13.0** *(shipped 2026-05-15)* — D-5 maker-checker dual-approval: `nova promote` restructured as a sub-group (`direct` / `propose` / `approve`). Ed25519 keypair auto-generated per identity. SoD enforced at the cryptographic level. Opt-in `maker_checker_gate.rego`. ADR-0058 + ADR-0018 amendment. 14 new tests.
- **v0.13.1** *(shipped 2026-05-15)* — DU-10 shared `<EmptyState>` component (`bordered` / `fill` / `inline` variants), applied to HoldsTab, AuditTab, RunsTab, EvidenceList, RegistryBrowser, LineageGraph. Eliminates 8 ad-hoc empty-state patterns.
- **v0.13.2** *(shipped 2026-05-15)* — DC-5 OPA trace viewer in PolicyTab. "explain" checkbox sends `explain: true` to `POST /api/policy/check`; backend fires `opa eval --explain full --format pretty`; `trace_text` returned in `PolicyDecision`; collapsible "show trace ↓" panel in the decision result card.
- **v0.13.3** *(shipped 2026-05-15)* — C-5 N-run diff. RunsTab checkbox cap lifted 2→5. Selecting 3–5 runs and clicking "Compare selected ⊕" enters DiffTab N-run mode: first run is baseline, N-1 parallel diffs fire, results shown as stacked collapsible cards with change-count badges. URL migrated from `?run_a=&run_b=` to `?run_ids=a,b,c`.
- **v0.13.4** *(shipped 2026-05-15)* — LineageGraph UX fix: double-clicking a node now selects it instead of triggering zoom-to-fit (`zoomOnDoubleClick=false`). Test coverage restored to 90%.
- **v0.14 P1** *(API only; UI in P2)* — Role-management REST surface. `POST/DELETE/GET /v0/admin/roles` (production) and `/api/admin/roles` (local). Last-admin lockout invariant at the store layer (returns 409). `nova server revoke-role` CLI added. ADR-0060. AdminTab "Roles" panel placeholder remains until P2 lands.
- **v0.15.2** *(shipped 2026-05-20)* — Track B dashboard scale: RunsTab cursor-pagination + SSE live feed, RegistryTab load-more, ComplianceTab with 4 panels for cap-001/002/004/005 evidence controls.
- **v0.16.0–v0.16.5** *(shipped 2026-05-20)* — GovernanceTab UI (4 panels: classify, audit, export-examiner, policy sign), SealTab inputClass crash fix, PBS/LSF runner dashboard support.
- **v0.17.0** *(shipped 2026-05-20)* — KGTab (6 panels: query, audit, entity queue, alias, init, ingest), full Evidence Fabric v1.0 coverage, TV-5 3D topology view.
- **v0.18.0** *(shipped 2026-05-20)* — DB-KG-1 new KGTab init/ingest panels; DB-CAP-1/ERA-1/STG-1 extensions in PolicyTab/ComplianceTab/InfraTab.
- **v0.19.0** *(shipped 2026-05-20)* — All CLI surfaces covered: ValidateDistributedBlock, KGInitPanel, NewRunIdPanel, DatabaseOpsPanel.
- **v0.20.0** *(shipped 2026-05-20)* — 7 new endpoints (unregister, doctor, policy test/explain, audit coverage/bundle/verify) + 4 tab panels.
- **v0.27.0** *(shipped 2026-05-20)* — Full CLI→dashboard parity: 11 new panels across SealTab, AdminTab×4, RegistryTab×2, InfraTab, RunsTab, LineageTab, GovernanceTab. 59 new tests.
- **v0.29.0** *(shipped 2026-05-20)* — KG MCPServer topology layer (SERVED_BY edge, `/api/kg/topology`, TopologyLayerPanel).
- **v0.30.0** *(shipped 2026-05-20)* — 3 new panels: CapsuleVerifyPanel (SealTab), OpenLineageExportPanel (LineageTab), SuggestRegisterPanel (RegistryTab).
- **v0.31.0** *(shipped 2026-05-20)* — KGQueryPanel, KGAuditPanel, EntityQueuePanel, KGAliasPanel; erasure-status + AuditMapPanel (ComplianceTab); children tree (RunsTab).
- **v0.34.0** *(shipped 2026-05-20)* — Dashboard parity for v0.32–v0.33 regulatory CLIs: RoCrateExportPanel, C2paExportPanel, ProvJsonExportPanel, EuAiActStatusPanel, EuAiActExportPanel.
- **v0.37.0** *(shipped 2026-05-20)* — ComplianceTab gains 4 new panels: **GDPR Art.30 RoPA Export** (optional controller fields, completeness badge), **AI-SBOM Export** (CycloneDX 1.7 component list, v0.39.0), **NIST AI RMF Report** (GOVERN/MAP/MEASURE/MANAGE score bars, risk-level badge), **AI-SBOM Coverage Status** (auto-loads, 3-stat grid, CRA deadline 2026-09-11). Four new `nova serve` endpoints: `POST /api/compliance/export/ropa|aibom|nist-rmf`, `GET /api/aibom/status`.
- **v1.0** — Graduation review. The `--experimental` flag is removed only when a follow-up ADR explicitly proposes it.

The dashboard is judged on whether it earns its keep. If at any point it becomes maintenance-heavy without commensurate user value, the entire `src/novafabric/serve/` package can be deleted in a single commit and ADR-0027 reverted, with little migration debt: what the dashboard persists is plain files (the token stores, its audit log, `erasure.db`, views and dashboards — see the security notes above), not a schema of its own.

---

## Downloading chart images (ADR-0201; shipped v0.64.0)

Every chart wrapped in the shared `ChartCard` (Analytics run-volume + duration
charts, the Reports-tab chart preview, the Cost tab's cost-by-model chart) has a
compact **⬇ SVG / PNG** affordance. Export happens entirely client-side: the
on-screen SVG is serialized with its *computed* styles inlined — so theme
tokens resolve to the concrete colors of your active light/dark theme — and PNG
is rasterized through a canvas at 2× for crisp retina output. No server round
trip, no new dependencies.

## Report artifacts: HTML + PDF with charts (ADR-0200/0201; shipped v0.64.0)

The Reports tab is backed by a server-side **report registry**
(`GET /api/reports/catalog`) that declares, per report, its filters and — only
where a genuine series exists — a chart spec (throughput stacked-bar, cost-burn
bar, single-suite eval-regression line, release-comparison delta bar; nothing
is fabricated for tabular-by-nature reports). Besides CSV/JSON, every report
exports as:

- **HTML** — one self-contained file (no JS, no external requests) with the
  inline-SVG chart, the data table (row-capped with an explicit "showing first
  N of M" line — never silent truncation), and the canonical JSON embedded.
- **PDF** — the same page through WeasyPrint. Requires the optional extra
  (`pip install 'novafabric[compliance]'`); without it the endpoint returns
  `501` with that exact hint, which the UI surfaces verbatim.

## Parity classification (ADR-0200; shipped v0.64.0)

`ui/dashboard/src/components/dashboard/commands/commandParity.json` classifies **every**
`nova` CLI command as `real-panel` (a wired dashboard panel exists — claim is
machine-verified against the API client and serve routes), `builder-only`
(copy-only Commands-tab builder), or `cli-only` with a mandatory reason
(process lifecycle, destructive filesystem operation, CI-oriented).
`tests/serve/test_command_parity_classification.py` fails CI when a new CLI
command lands unclassified — parity can no longer regress silently. Destructive
operations staying `cli-only` *with a recorded reason* is the intended honest
end-state, not a gap.

## Scale characteristics and known limits

Understanding these limits prevents surprises when you grow beyond a small local
setup. The limits are documented here so they are visible before they bite.

### What works at scale today

| Mechanism | Where it lives | What it handles |
|---|---|---|
| Cursor-based pagination on `/api/runs/search` | `serve/app.py:42–66` | Fetches large run lists in pages without re-scanning from the start. A **cursor** is a bookmark: the server encodes `(created_at, run_id)` of the last returned item into a short base64 token. Send it back as `?cursor=...` to get the next page. Nothing to do with the VS Code text cursor. |
| `LIMIT`/`OFFSET` on assets and runs | `serve/app.py` | Registry and runs list endpoints cap result sizes. |
| TanStack Virtual in RunsTab and RegistryTab | `ui/dashboard/src/components/dashboard/tabs/` | Renders only the rows visible on screen — the DOM stays small even with thousands of rows loaded in memory. |
| SSE live feed for new capsules | `/api/runs/stream` | New capsule directories appear in the RunsTab without a full page reload. |
| 5 SQL indexes on registry DB | `serve/app.py` (v0.14.6) | Keeps `WHERE status = ?` and `ORDER BY created_at` fast at tens of thousands of assets. |
| Shared keyset cursor codec + page envelope (ADR-0199, shipped v0.64.0) | `serve/pagination.py` | One `(created_at, id)` row-value cursor implementation reused by runs, run-history reports, and the audit log. |
| Reverse-block audit-log tail reads (ADR-0199, shipped v0.64.0) | `serve/audit.py::read_recent_tail` | `/api/audit` (and the alerts feed) read O(limit) 64 KiB blocks from EOF with byte-offset cursors instead of `readlines()` on the whole append-only file; AuditTab is virtualized with server-side action filter + load-more. |
| SQL aggregation pushdown for reports + analytics (ADR-0199, shipped v0.64.0) | `registry/runs_cache.py` aggregate functions + `substr(created_at,1,10)` expression index | Throughput, executive-summary, cost-burn, and the Analytics day buckets are computed in indexed GROUP BYs — the former `limit=1_000_000` fetch-all is gone. Run-history is keyset-paged and its CSV export streams in pages (bounded memory at any store size). |
| Nightly 100K-row scale gate (ADR-0199, shipped v0.64.0) | `tests/bench/test_dashboard_scale_gate.py` + the `dashboard-scale` CI job | p95 latency thresholds per endpoint, recorded in [`docs/ops/dashboard-scale.md`](https://novafabric.ai/docs/ops/dashboard-scale/); regressions block release. |
| True keyset fast path on `/api/runs/search` (ADR-0199, B2) | `serve/app.py` + `registry/runs_cache.py::query_runs(after=)` | The decoded `(created_at, run_id)` cursor now seeks directly in the index — the former cursor→`OFFSET` `COUNT(*)` conversion (O(offset) per page) is gone; deep pages cost O(page). |
| Bounded policy audit reads (ADR-0199, B2) | `/api/policy/recent-decisions`, `/api/policy/explain` | Both iterate the dashboard audit log newest-first via `audit.tail_lines` (O(page) reverse 64 KiB blocks, ≤10,000 scanned lines per request) instead of reading the whole file; byte-offset `cursor`/`next_cursor` (same contract as `/api/audit`) plus `truncated`. `explain` gained `limit` (default 100, max 1000) and now returns matches **newest-first**. `explain` needs the `audit` scope and matches `decision_id` exactly (an empty or wildcard id is 422). |
| Keyset cursor + totals on `/api/lineage/edges` (ADR-0199, B2) | `serve/app.py` | Additive `cursor`/`next_cursor` over `(created_at, edge_id)`, true `total` edge count, and `truncated: true` when older edges were not returned; existing `count`/`edges` keys unchanged. |
| Bounded KG entity queue (ADR-0199, B2) | `/api/kg/entity-queue` + `kg/review_queue.py::list_pending(limit=)` | `limit` (default 200, max 1000) is pushed into the SQL read; response gains the true pending `total` and `truncated`. |
| Bounded admin listings (ADR-0199, B2) | `/api/admin/tokens`, `/api/admin/api-keys` | Tokens are read newest-first from `tokens.jsonl` via the reverse tail scan with a byte-offset `cursor` (`limit` default 200, max 2000; a revoke rewrites the file and invalidates outstanding cursors). API keys push `limit` (default 500, max 2000) into SQL; `total` is now a true `COUNT(*)` and `truncated` is additive. |
| Bounded asset eval history (ADR-0199, B2) | `/api/assets/{asset_id}` | `eval_limit` (default 50, max 500) bounds `eval_results`; additive `eval_results_total` + `eval_results_truncated` — no silent truncation. |
| Watermark cache on `/api/analytics/summary` (ADR-0199 rule 3) | `serve/routers/analytics.py` | A cheap indexed `(COUNT(*), MAX(created_at))` watermark per window skips the O(rows) duration-percentile pass when nothing changed — the 30 s polling loop recomputes only when new runs actually landed (bounded 32-window cache). |
| Shared truncation affordance in the UI (ADR-0199, shipped v0.97.0) | `ui/dashboard/src/components/ui/TruncationNotice.tsx` + `lib/{useQuery,usePaginatedQuery}.ts` | The client-side half of "no silent truncation": every bounded list renders a footer reading **`Showing N of M — K more`** with a **Load more** button, and a `~` prefix plus an *approximate* badge whenever the server reported an estimated total (`total_approx`) rather than an exact `COUNT(*)`. `usePaginatedQuery` speaks **both** server models — keyset cursor and `limit`/`offset` — so a list never has to choose between honesty and paging; DataTable additionally loads the next page on scroll. If a list can show you less than exists, it says so. |
| Conditional GET on all hot polled reads (ADR-0199 S6, B3) | `serve/http_cache.py::conditional_json` on `/api/stats`, `/api/runs`, `/api/alerts/recent`, `/api/incidents`, `/api/evidence`, `/api/kg/topology`, `/api/reports/catalog` (plus the original `/api/analytics/summary`) | Content-addressed strong ETags + `Cache-Control: private, max-age` — the dashboard's 30 s polling loop gets `304 Not Modified` (empty body) whenever the underlying data hasn't changed, cutting transfer and client re-render on every idle poll. |

### Known hard limits (action items for future versions)

| Limit | Root cause | Planned fix |
|---|---|---|
| **~10,000 capsules** before `/api/runs` slows noticeably | `list_run_summaries()` (`serve/capsule_loader.py:51`) re-reads every `capsule.yaml` from disk on every request — O(N disk). | **Scale-S1 (shipped v0.32.0) + Scale-S3 (shipped v0.36.0):** `runs_cache` SQLite index + `CapsuleWatcher` background indexer; `nova ingest-capsule` CLI for manual re-index. |
| **NovaSeal Merkle log verify** is O(N) on either backend | `serve/app.py`, `nova seal log verify` | **Scale-S4 shipped v0.38.0** — set `NOVAFABRIC_SEAL_DB_PATH=postgresql://…` for the Postgres Merkle log. ⚠ Postgres removes the SQLite table scan but **not** the O(N) cost: the root is recomputed from every leaf either way (~1.9 s at 1M entries). The originally-published p99 < 200 ms figure was never met and is withdrawn by decision (ADR-0268): full recompute is the guarantee and its linear cost is documented — see `docs/releases/v0.38.0.md`. |
| **SQLite registry** breaks at ~1M rows | No partitioning in SQLite | Flip `NOVAFABRIC_METADATA_BACKEND=postgres` — the Postgres tier (`PostgresMetadataStore`) is already implemented. |

### Storage backends — three separate systems

Each backend is upgraded independently. Flipping one does not affect the others.
See [Storage Architecture](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md#production-storage-stack-polyglot-persistence) for the full reference table and env var names.

| Store | Default | Scalable tier |
|---|---|---|
| Capsule content | Flat files under `$NOVAFABRIC_HOME/capsules/` — always filesystem | Object Capsule Store (`NOVA_OCS_BACKEND`) |
| Registry + lineage | `~/.novafabric/registry.db` (SQLite) | `NOVAFABRIC_METADATA_BACKEND=postgres` + `NOVAFABRIC_METADATA_DSN=...` |
| NovaSeal policy + Merkle log | `$NOVAFABRIC_HOME/novaseal-merkle.db` (SQLite; or `merkle_db:` in `novaseal.yaml`) | `NOVAFABRIC_SEAL_DB_PATH=postgresql://…` (`PostgresMerkleLog`, Scale-S4, shipped v0.38.0) |

---

## KG topology vs. Live Topology

The dashboard exposes two topology views that are easy to confuse:

| | KG tab → Multi-Layer Topology | Top-right "Topology" button |
|---|---|---|
| Backing store | KuzuDB on disk (persistent) | In-memory DuckDB + ring buffer (ephemeral) |
| Node types | Agent, Model, Tool, MCPServer, InferenceEndpoint | Agent only |
| Updated by | Re-ingest All / auto-poll (60 s) | Running agents in real time (< 5 s) |
| Survives restart | ✓ | ✗ (re-seeded from disk at startup) |
| Queryable | ✓ (`nova kg query`) | ✗ view only |
| Purpose | Historical audit: what called what | Operational: what is running now |

**Re-ingest All** only updates the KG topology. It has no effect on the live Topology view.
**Restarting `nova serve`** re-seeds the live Topology from disk. It does not change KuzuDB.

Full comparison with update-trigger matrix: [KG topology vs. Live Topology](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md#kg-topology-vs-live-topology).

---

## See also

- [ADR-0027](https://novafabric.ai/docs/decisions/) — full architectural decision record.
- [what NovaFabric is not](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md#what-novafabric-is-not) — the original "no web UI through v1.0" stance and its narrow exception.
- [`docs/cli-reference.md`](https://novafabric.ai/docs/cli-reference/) — the canonical CLI reference. Every dashboard action maps to a command listed there.
- [ADR-0011 — Evidence Bundle](https://novafabric.ai/docs/decisions/) — the signing key model the dashboard reuses.
- `ui/dashboard/` (in the repo) — the dashboard's source. Since ADR-0299 `nova serve` ships the dashboard only: `/` opens it, explanatory pages are external links to [novafabric.ai](https://novafabric.ai/), and nothing the dashboard does requires internet access.
- [ADR-0036](https://novafabric.ai/docs/decisions/) — cross-run comparison UX design (v0.9).
- [ADR-0037](https://novafabric.ai/docs/decisions/) — Evidence Tab native implementation (v0.9).
- [ADR-0038](https://novafabric.ai/docs/decisions/) — Eval trend chart in Registry (v0.9).
