---
title: "NovaFabric REST API Reference"
description: "The nova serve dashboard server exposes 208 REST endpoints in the generated tables below, plus the 14 experimental routes in the next section that the…"
canonical: https://novafabric.ai/docs/api-reference/
last_updated: 2026-10-24
source: https://github.com/MSKazemi/novafabric/blob/main/docs/api-reference.md
---

# NovaFabric REST API Reference

**Status:** works today (v0.98.0). Generated from the live `nova serve` FastAPI route table (`src/novafabric/serve/app.py`).

The `nova serve` dashboard server exposes **208 REST endpoints** in the generated tables below, plus the 14 experimental routes in the next section that the generator has not yet been re-run for — 222 in all, every route `create_app` can mount across its flags (`GET /` and the `/api/topology/*` routes are conditional). The authorization table classifies 225, the other three being the TV-5 routes `nova serve` mounts after the app is built. All require a valid auth token except the seven `public` routes: the probes `GET /api/health`, `GET /livez` and `GET /readyz`, the interactive docs `GET /api/docs`, `GET /api/openapi.json` and `GET /docs/oauth2-redirect`, and `GET /` (the placeholder served when no dashboard is mounted). The multi-tenant `nova server` API is specified separately in `api/openapi.yaml`.

## Authorization (ADR-0228, experimental)

A valid token is necessary but no longer sufficient. Every route carries one of four scopes,
enforced by a single declarative table (`src/novafabric/serve/authz.py`):

| Scope | Reaches | ADR-0027 layer |
|---|---|---|
| `read` | every read-only endpoint, **including the `POST` routes that only compute and return** — `/api/query`, `/api/policy/check` (packaged policies only), `/api/mcp/scan`, `/api/kg/blast-radius`, and the whole `/api/compliance/export/*` family | 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 (none of the three re-executes the workload; each writes a replay result beside the capsule) | Layer C |
| `audit` | everything `read` reaches **plus** `GET /api/audit`, `GET /api/reports/dashboard-audit`, `GET /api/reports/policy-audit` (and their `/export`), `GET /api/policy/recent-decisions`, `GET /api/policy/explain`, `GET /v1/kg/audit` — and **never** `operate` or `admin` | orthogonal |

`admin` ⊇ `operate` ⊇ `read`, and `admin` also reaches `audit`. Insufficient scope returns
**403** with the required and held scope in `detail`, and appends an `authz.denied` record to
the dashboard audit log. A missing or unrecognised credential on a classified route still
returns **401**, and writes nothing.

Two rules sit on top of the table:

- **A derived route is never weaker than its base.** `GET /api/reports/{report_id}/export` needs the scope of `GET /api/reports/{report_id}`: exporting `dashboard-audit` or `policy-audit` needs `audit`, exporting `api-key-inventory` needs `admin`, and an unknown report id needs `admin` (so a non-admin gets 403, not 404).
- **Rego the server did not ship needs `admin`.** `POST /api/policy/check` with a non-blank `policy_source`, and `POST /api/policy/test` with a `bundle_path`, return **403** below `admin` (audited like any other denial). A `read` credential evaluates and tests the packaged policies only.

Whoever asks, every `opa` that `nova serve` (or the CLI) starts is **sandboxed**: it gets no environment from the server except a fixed `PATH`, and runs with an OPA capabilities file derived from the installed binary minus `opa.runtime`, `http.send` and `net.lookup_ip_addr`, with no network allowed. Rego that calls one of those fails to compile and the decision is a deny whose `reason` names the undefined function. If the capabilities cannot be derived (an `opa` too old for `opa capabilities --current`), nothing is evaluated and the decision is a deny. Request-path evaluations run in a worker thread with a 10 s timeout per `opa` call.

**The default is unchanged:** the `.serve-token` holds `admin`, as does any token issued before
this landed (a `tokens.jsonl` record with no `scope` field). Mint a narrower one with
`POST /api/admin/tokens` with a `scope` field and `"confirmed": true` (without it the answer is
**400** and nothing is minted). The scope must be exactly `read`, `operate`, `admin`
or `audit`; anything else is refused with **422** and nothing is stored. A stored record whose
`scope` is not one of those (for example a hand-edited typo) holds **no** scope: it is refused with
403 on every non-public route, and `GET /api/admin/tokens` lists it as `none`.

The audit fingerprint of the server token (`actor_token_fp` in route records,
`session_token_fingerprint` in `GET /api/admin/tokens`, `user.uid` in the SIEM export) is 12 hex
characters of a domain-separated SHA-256 of the token. It is stable, and it is never a prefix of
the token.

### Paths in requests

Fields that name a server-side file or directory (`output_path`, `key_path`, `cert_path`,
`db_path`, `source`, `output`, `capsule_path` (body, and the `?capsule_path=` query of
`GET /api/assure/{run_id}`), `capsule_base`, `bundle_path`, `target_db`, a non-built-in
compliance `profile`, and an `/api/ingest-capsule` `run_id` that contains a `/`) are
confined to allowlisted roots. A **write** may target only `$NOVAFABRIC_HOME/exports`,
`$NOVAFABRIC_HOME/evidence` or `$NOVAFABRIC_EVIDENCE_DIR`. A **read** may also target
`$NOVAFABRIC_HOME` and the server's capsule directory. Each path is resolved, symlinks included,
before it is checked. A `..` segment, or a path that resolves outside the roots, is refused with
**403**. An empty path or a NUL byte is refused with **400**. Relative paths are taken from
`$NOVAFABRIC_HOME` (reads) or `$NOVAFABRIC_HOME/exports` (writes). Operators add directories with
`NOVAFABRIC_SERVE_PATH_ROOTS` (absolute paths, `os.pathsep`-separated).
`NOVAFABRIC_SERVE_ALLOW_ANY_PATH` is no longer honoured.

The same roots bound **run ids**. An entry of the capsule directory that is a symlink is
never listed by `/api/runs`, `/api/runs/search`, the reports, the cost report or any other
route that scans capsules. Named directly (`/api/runs/latest`), it is served only when it
resolves inside the read roots; otherwise every `{run_id}` route answers **403**. A capsule
whose top level holds a symlink or a special file (FIFO, socket, device) is refused with **403**,
and a symlinked or special file in a *sibling* capsule is read as absent by every scanning route.
See [Links inside a capsule are never followed](https://novafabric.ai/docs/concepts/#links-inside-a-capsule-are-never-followed-works-today).

A **read** never reaches the server's own secrets, even inside a root: the token stores
(`tokens.jsonl`, `.serve-token`, `.server-token`), `.env`, the PII key store (`dek.db` and its
SQLite sidecars), the NovaSeal ratchet state, `config.yaml` and the audit logs are refused with
**403**, and so is a directory that contains one of them (`$NOVAFABRIC_HOME` itself, for
example). A source read (`source`, `capsule_path`, `capsule_base`, `bundle_path`, `profile`)
also refuses key material: `$NOVAFABRIC_HOME/keys`, the NovaSeal keyring, and any `*.pem` or
`*.key` file. `key_path`/`cert_path` may name key material, which the server uses to sign and
never returns.

Values a route joins onto a server directory as one name (`node_id` on `/api/seal/ratchet/*`,
`capsule_id` on `/api/seal/sigstore/*` and `/api/seal/{capsule_id}/...`, `run_id` on
`/children` and `/validate-distributed`) are refused with **400** if they contain `/`, `\`,
`..`, a NUL or other control character, are absolute, or are longer than 255 bytes (one rule,
shared with `nova server`'s `/v0` routes and `nova import`); an evidence-cart `cart_id` must match `[A-Za-z0-9_-]+`
(**422** otherwise). Ratchet responses never include the chain key.

### Capsule files and symbolic links

No route returns the target of a symbolic link planted in a capsule.
`GET /api/runs/{run_id}/file/{path}` answers **404** for a link or a special file; a capsule
with a link at its top level is refused with **403** by every `/api/runs/{run_id}/...` route;
`POST /api/compliance/export/rocrate` answers **422** for a capsule holding any link. See
[Concepts: links inside a capsule](https://novafabric.ai/docs/concepts/#links-inside-a-capsule-are-never-followed-works-today).

### Single-actor promotion into a protected stage

`POST /api/assets/{name}/{version}/promote` and `POST /api/assets/{asset_id}/promote` apply the
same ADR-0312 gate as `nova promote direct`: `production` needs an explicit `promote_direct`
policy allow, and `force` does not bypass it. Without one, the answer is **403**. Without OPA there
is never an explicit allow. Reach `production` through `nova promote propose` + `approve`. The
policy subject is `serve:<credential fingerprint>`, not the request's `actor` field.

Registration is not a way in either. `POST /api/assets` registers every asset as
`development`; a spec that declares any other `status` is refused with **422** (audited as
an error) and `POST /api/assets/register-from-yaml` answers `ok: false`. A version that was
ever in production cannot be registered again (**409**). A rollback restores, without a
policy decision, only a version whose *current* registration has production history; see
[`nova rollback`](https://novafabric.ai/docs/cli-reference/registry/#nova-rollback-name---actor-id).

### Confirmation on state-changing routes

Nineteen `POST` routes refuse to act unless the JSON body carries `"confirmed": true`; without it
they answer **400** and change nothing:
`/api/admin/tokens`, `/api/admin/rebuild-metadata-db`, `/api/admin/reindex-runs`, `/api/assets`,
`/api/assets/{asset_id}/approve`, `/api/assets/{asset_id}/eval`,
`/api/assets/{asset_id}/promote`, `/api/assets/{name}/rollback`,
`/api/assets/{name}/{version}/eval`, `/api/assets/{name}/{version}/promote`,
`/api/compliance/erasure/request`, `/api/evidence/{run_id}`, `/api/evidence/cart/export`,
`/api/runs/{run_id}/redact`, `/api/seal/ratchet/rotate`, and the four
`/api/runs/{run_id}/replay/{dry-run,exact,forensic,semantic}` routes. The flag is a guard
against an accidental click, not an authorization step: the scope check
runs first.

## Aggregates and the honest-degradation rule (ADR-0234, experimental)

> An aggregate that cannot be computed faithfully must refuse, and say why. It may not render an
> approximation, a partial result, or a zero in place of an unavailable one.

Endpoints that return an aggregate carry an additive `aggregate` block:

```json
{"computable": false,
 "condition": "source_unavailable",
 "reason": "cost aggregation needs the ClickHouse cost store, which is not configured; …",
 "remedy": "set NOVA_CLICKHOUSE_URL … or read per-run cost from the capsule's cost facet instead"}
```

A refusal carries **no `value`** — not zero, not null. A computable verdict carries
`{"computable": true, "value": …}` and may carry non-fatal `notes`.

| `condition` | Meaning |
|---|---|
| `source_unavailable` | the backing store is not configured or did not answer |
| `truncated_source` | the underlying set is bounded (ADR-0199); a total over it is not a total |
| `absent_contributor` | a contributing field is legitimately absent — e.g. an unpriced model call |
| `unpushable_filter` | a filter could not be pushed into the aggregation |
| `tenant_unsafe_store` | ADR-0229 declares a required store unable to answer per tenant |

**`GET /api/runs/cost-summary`** is the first consumer. Each run additionally reports
`unpriced_calls` and `cost_is_complete`: `CostInterceptor._estimate_cost` returns `0.0` for a model
with no catalog price, so without these a run on an unpriced model reported `$0.00` and read as
free. A selection in which *every* call is unpriced now refuses rather than reporting zero.

⚠ **Zero is still a legitimate value.** "No runs failed in this bucket" is a measured fact. The
rule is about *unmeasured* quantities, never about measured zeroes.

## Tenancy (ADR-0229, experimental)

`GET /api/doctor` includes a `tenancy_posture` check reporting, for each of the eight stores in
the read path, whether it is `aware`, `agnostic`, or `unsafe`, plus a `blocking` list. Three are
unsafe today — the runs index, the knowledge graph, and the lineage store — so **multi-tenant
mode is refused at startup** (`NOVAFABRIC_SERVE_TENANCY=multi` makes `nova serve` exit 2 rather
than serve cross-tenant reads). Every endpoint below therefore answers for a single tenant.
For multi-tenant deployments use `nova server` (`api/openapi.yaml`).

A route absent from the table is **denied to everyone**, including the server token and callers
that send no credential at all — a CI guard fails on the first unclassified route, so this should
never be reachable in a release.

> Generated by `design/scripts/gen_api_reference.py` (private) — re-run it after route changes rather than editing endpoints by hand.

> **TV-5 3D topology routes (experimental).** When the server is started with `nova serve --tv5`,
> an additional router is mounted at `/api/tv5/` (`src/novafabric/serve/topology/router_tv5.py`):
> `GET /api/tv5/live` (Arrow delta stream), `GET /api/tv5/windows`,
> `GET /api/tv5/snapshot/{window_id}`, and a `WS /api/tv5/ws` websocket. These are conditional
> and eval-gated, so they are **not** in the generated table above.
> **Since v0.97.0 they are authenticated** — the HTTP routes are token-gated like every other
> `/api/*` route, and the WebSocket enforces the localhost `Host` guard (close code **4403**)
> and the token (close code **4401**) *before* accepting the connection — on the WebSocket
> the token must arrive as a `?token=` query parameter, since browsers cannot set headers on
> a WS connect. Previously this router was mounted with no auth at all; a custom TV-5 client
> that connected without a token must now supply one.


## Dashboards, saved views, filter bar and evidence cart (experimental)

*Status: **experimental**. Hand-maintained until the generator is re-run; the authority is the
route table in `src/novafabric/serve/authz.py`.* User-facing behaviour is in
[dashboard.md](https://novafabric.ai/docs/dashboard/).

| Method | Path | Scope | Summary |
|---|---|---|---|
| `GET` | `/api/filter/parse` | `read` | Parse a filter-bar string with the `nova query` DSL parser (ADR-0232). |
| `GET` | `/api/filter/runs` | `read` | Runs matching a filter, with `scope` node, root or tree (ADR-0233); reports "N of M" and truncation. |
| `GET` | `/api/filter/suggest` | `read` | Observed values for one filterable dimension. |
| `GET` | `/api/views` | `read` | List `nova view` files, each with its Runs-view form or the reason it has none (ADR-0232 D4). |
| `POST` | `/api/views` | `operate` | Save the Runs view state as a `nova view` file; audited. |
| `DELETE` | `/api/views/{view_id}` | `operate` | Remove one saved view; audited. |
| `GET` | `/api/dashboards` | `read` | List dashboards and widgets under `$NOVAFABRIC_HOME/dashboards`; refused files are listed with the reason (ADR-0235). |
| `GET` | `/api/dashboards/{dashboard_id}` | `read` | One dashboard (422 with the reason if the stored file is refused). |
| `GET` | `/api/dashboards/{dashboard_id}/export` | `read` | The stored JSON, byte for byte. |
| `GET` | `/api/dashboard-widgets/{widget_id}/data` | `read` | Run only the query stored in the widget file; the request carries no query text (ADR-0236 `ratio()` included). |
| `GET` | `/api/dashboard-widgets/{widget_id}/export` | `read` | The stored widget JSON, byte for byte. |
| `POST` | `/api/dashboards/validate` | `read` | Dry run: the verdict and diff inputs; writes nothing. Body over 256 KiB is a 413. |
| `POST` | `/api/dashboards/apply` | `operate` | Validate then store atomically; 409 if the file changed since the preview digest; refuses `builtin`, symlinked targets and bad ids; audited as `dashboard_apply`. |
| `POST` | `/api/evidence/cart/export` | `admin` | Resolve a cart once and write one signed Evidence Bundle (ADR-0239); one export at a time (429). |

## Health & meta  (9)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/` | serve no dashboard |
| `GET` | `/api/adapters` | List registered Nova framework adapters with availability flags. |
| `GET` | `/api/docs` | swagger ui html |
| `GET` | `/api/doctor` | Run diagnostic checks on the NovaFabric installation (nova doctor). |
| `GET` | `/api/health` | health |
| `GET` | `/api/openapi.json` | openapi |
| `GET` | `/api/stats` | Aggregate counts for the HomeTab. |
| `POST` | `/api/validate-spec` | Validate an asset YAML spec without registering — mirrors `nova validate <spec>`. |
| `GET` | `/docs/oauth2-redirect` | swagger ui redirect |

## Runs & capsules  (34)

| Method | Path | Summary |
|---|---|---|
| `POST` | `/api/capsule-migrate` | Migrate a v0.1.x capsule directory to v1.0.0 format (ADR-0034 §6). `source` must be under a read root and `output` under a write root (see *Paths in request bodies*). |
| `POST` | `/api/otlp/v1/traces` | Ingest OTLP OTel GenAI spans into a run capsule (NF-034, experimental). |
| `POST` | `/api/otlp/v1/logs` | Ingest OTLP log records into the sidecar log store, never into a capsule (ADR-0293, experimental). The body is read bounded: over 16 MiB is a 413 before it is buffered. |
| `GET` | `/api/runs` | list runs |
| `GET` | `/api/runs/cost-summary` | Return per-run token and cost totals from ClickHouse. |
| `GET` | `/api/runs/search` | Cursor-based run listing. True keyset paging: the cursor seeks the index directly — O(page), no cursor-to-offset conversion (ADR-0199 B2). |
| `GET` | `/api/runs/stream` | Server-Sent Events stream of new run summaries. |
| `GET` | `/api/runs/suggest-register` | Return asset registration suggestions derived from recent capsules. |
| `DELETE` | `/api/runs/{run_id}` | Delete a capsule directory, subject to legal holds and retention policy. |
| `GET` | `/api/runs/{run_id}` | get run |
| `GET` | `/api/runs/{run_id}/children` | Return parent capsule metadata + list of child capsule summaries. A sealed capsule's `status` and `exit_code` come from its signed payload (`.seal/manifest.dsse`), never from an unsealed `capsule.yaml`/`capsule.json` beside it. |
| `GET` | `/api/runs/{run_id}/diagnose` | Attribute a failed run to its most likely responsible step. |
| `GET` | `/api/runs/{run_id}/energy` | Energy-Anchored Action Receipts + conservation for a run (ADR-0093). |
| `POST` | `/api/runs/{run_id}/export-system-card` | Generate and SEAL an auto-generated system/audit card. |
| `GET` | `/api/runs/{run_id}/file/{filepath:path}` | get run file |
| `GET` | `/api/runs/{run_id}/forensics-timeline` | Reconstruct a deterministic forensics timeline from a run's sealed capsule (ADR-0155 D1; see note below). |
| `GET` | `/api/runs/{run_id}/ledger` | Adversary-anchored ledger verification status (ADR-0094). |
| `POST` | `/api/runs/{run_id}/redact` | redact endpoint |
| `GET` | `/api/runs/{run_id}/redaction-proof` | get redaction proof endpoint |
| `GET` | `/api/runs/{run_id}/redaction-xray` | Field-protection overlay for one capsule (ADR-0174). |
| `POST` | `/api/runs/{run_id}/replay/dry-run` | dry run replay endpoint |
| `POST` | `/api/runs/{run_id}/replay/exact` | exact replay endpoint |
| `POST` | `/api/runs/{run_id}/replay/forensic` | forensic replay endpoint |
| `POST` | `/api/runs/{run_id}/replay/semantic` | semantic replay endpoint |
| `GET` | `/api/runs/{run_id}/run-lineage` | List spool lineage edges for a distributed run — nova run lineage. |
| `GET` | `/api/runs/{run_id}/safety-case` | Compile an evidence-grounded safety case for a run (ADR-0095). |
| `GET` | `/api/runs/{run_id}/scan-secrets` | Report secret/PII findings from the redaction log — nova scan-secrets. |
| `POST` | `/api/runs/{run_id}/scores` | Append one externally-computed score to the run's ``scores.jsonl``. |
| `GET` | `/api/runs/{run_id}/tool-permission-events` | Return ToolPermissionEvent records for a capsule. |
| `GET` | `/api/runs/{run_id}/tree` | Show the parent/child capsule tree — nova run show --with-children. |
| `GET` | `/api/runs/{run_id}/trust-radar` | Trust guarantees for one capsule (ADR-0173). |
| `POST` | `/api/runs/{run_id}/validate` | Validate a capsule's schema and required files. |
| `POST` | `/api/runs/{run_id}/validate-distributed` | Validate a distributed parent capsule + its workers (nova run validate-distributed). |
| `POST` | `/api/runs/{run_id}/verify` | Verify DSSE signature + RFC 3161 timestamp + Merkle log inclusion + the ADR-0251 capsule binding with the same function as `nova verify <capsule-dir>`. Without a `novaseal.yaml` on the host it verifies from the capsule alone, as `nova verify` does, and returns every check with `configured: false` (up to v0.106.0 it returned only `configured: false` and verified nothing). An unusable `novaseal.yaml` is `valid: false`. Opens configured logs read-only; creates nothing. |

### Content search on `GET /api/runs/search` — `scope` parameter (experimental, ADR-0204)

`GET /api/runs/search` accepts an additive `scope=meta|content|all` query
parameter (default `meta`):

- `scope=meta` (or omitted) — the existing behavior, unchanged: `q` matches
  `run_id` and command metadata only.
- `scope=content` — **experimental**: full-text search (SQLite FTS5) over the
  *redacted* capsule text indexed at ingest (ADR-0204). Requires a non-empty
  `q` (else `400`). Each item carries a `matches` array of
  `{stream, ref, line_no, snippet}` plus `matches_truncated` (max 8 matches
  per run); the response adds `snippet_markers` (`["«", "»"]`). Results are
  relevance-ranked (bm25); `next_cursor` is always `null` for content scopes
  in v0. User query input is quoted before it reaches the MATCH grammar —
  FTS5 operators (`OR`, `NEAR`, `-`, `:`) match literally.
- `scope=all` — **experimental**: content hits first (with `matches`), then
  metadata hits in the existing newest-first order, deduplicated by `run_id`.

If the host SQLite lacks FTS5, content scopes return a `501` error envelope
(`{"error": {"code": "fts5_unavailable", …}}`); `scope=meta` is unaffected.
Postgres (`nova server`) parity is **planned** — no target version.

### `GET /api/runs/{run_id}/forensics-timeline` (ADR-0155 D1)

Reconstructs a deterministic forensics timeline from a run's own sealed
capsule via the pure `forensics/timeline.py::merge_timeline` core — the same
merge `nova forensics timeline` uses. **Honest scope:** it only reconstructs
what the capsule already carries (run start/finish, model-call and
tool-call events with a usable timestamp); a documented follow-on "incident →
session → lineage collector" does not exist yet, so lineage coverage is
always reported as a gap, never fabricated. Each source is capped
(`MAX_EVENTS_PER_SOURCE`); anything beyond the cap is a gap, not a silent drop.

## Assets & registry  (19)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/assets` | list assets endpoint |
| `POST` | `/api/assets` | register asset endpoint |
| `POST` | `/api/assets/register-from-yaml` | Register an asset from a YAML spec string (used by suggest-register panel). |
| `GET` | `/api/assets/{asset_id}` | Asset detail with its recent eval history (bounded: `eval_limit` default 50, max 500; adds `eval_results_total`/`eval_results_truncated`). |
| `GET` | `/api/assets/{asset_id}/approvals` | Return approval records for an asset (by UUID). |
| `POST` | `/api/assets/{asset_id}/approve` | approve asset endpoint |
| `POST` | `/api/assets/{asset_id}/eval` | Eval by UUID — resolves name+version from the registry then delegates. |
| `GET` | `/api/assets/{asset_id}/eval-history` | eval history endpoint |
| `POST` | `/api/assets/{asset_id}/promote` | Promote by UUID — resolves name+version from the registry then delegates (same ADR-0312 gate). |
| `GET` | `/api/assets/{name}/diff` | asset spec diff endpoint |
| `POST` | `/api/assets/{name}/rollback` | Roll back to the most recent version the registry's status history shows was in production (ADR-0312 D1); never to a version without production history. 409 when there is none; a gated target without a `promote_direct` allow is 403. |
| `DELETE` | `/api/assets/{name}/{version}` | Hard-delete an asset from the registry (nova unregister). |
| `GET` | `/api/assets/{name}/{version}` | get asset endpoint |
| `POST` | `/api/assets/{name}/{version}/eval` | eval asset endpoint |
| `POST` | `/api/assets/{name}/{version}/promote` | Single-actor promotion, gated exactly like `nova promote direct` (ADR-0312): `production` needs an explicit `promote_direct` policy allow (403 otherwise); `force` does not bypass it. |
| `GET` | `/api/diff` | diff two runs; returns `summary` (changed/added/removed), `sections` (the `nova diff --output-format json` sections) and `has_changes` (the `--assert-no-regressions` property) beside the legacy `report` |
| `POST` | `/api/eval/compare` | Compare two EvalResult JSON objects for regression — mirrors `nova eval compare`. |
| `POST` | `/api/eval/run` | Run a standard eval suite against a capsule — nova eval run. |
| `GET` | `/api/eval/suites` | List registered eval suite adapters — nova eval list. |

## Lineage  (8)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/lineage/blast-radius/{ref:path}` | lineage blast radius |
| `GET` | `/api/lineage/edges` | Return lineage edges (newest-first). Powers the dashboard's full-DAG view. Keyset `cursor`/`next_cursor` over `(created_at, edge_id)`, plus true `total` and `truncated` (ADR-0199 B2). |
| `POST` | `/api/lineage/export-prov` | Export W3C PROV-JSON lineage document for a capsule. |
| `POST` | `/api/lineage/import` | Import capsule lineage events into the store — mirrors `nova lineage import`. |
| `GET` | `/api/lineage/provenance/{ref:path}` | lineage provenance |
| `GET` | `/api/lineage/replay-chain/{run_id}` | lineage replay chain |
| `GET` | `/api/lineage/time-travel/{ref:path}` | lineage time travel |
| `GET` | `/api/lineage/{run_id}/emit-openlineage` | Return OpenLineage events for a capsule as JSON (nova lineage emit-openlineage). |

## Evidence & audit  (8)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/audit` | list audit |
| `GET` | `/api/evidence` | List Evidence Bundles (`*.zip` in the evidence directory), newest first. Only regular files are listed: a symlink or other special file named `*.zip` is skipped and not counted in `total`. `verified` is the verdict of the same verifier as `POST …/verify` under the operator trust list, cached per archive SHA-256 (a file-identity key reused a stale verdict on filesystems with 1 s timestamps; review r8); `run_ids` are the runs its verified run statements sign, `run_id` the signed run when exactly one verifies (else the bundle id), `signer_trust` as in `…/verify`, `members_signed` as in `…/verify`. `timestamp` comes from the bytes that were verified. `manifest.json` is read with a 64 MiB bound. Before 2026-10-23 `verified` compared a bare hash with the prefixed `manifest_hash` and was always false, and `run_id` was the unsigned manifest label. |
| `GET` | `/api/evidence/completeness/{run_id}` | Compute the completeness assertion for a capsule. |
| `GET` | `/api/evidence/{bundle_id}` | Bundle detail: manifest, decoded run statement, member list — the bundle's own content, shown as data — plus `verified`, `run_ids`, `run_id`, `signer_trust`, `members_signed` and `unsigned_members` from the shared verifier (as in the list). The archive is copied once into a private snapshot and the verdict and everything shown come from that copy, so a bundle rewritten in place during the request never shows forged content beside `verified: true` (review r8). `signing_key_fingerprint` is the `sha256:` fingerprint (SHA-256 of the SubjectPublicKeyInfo DER, as in `…/verify`'s `signer_fingerprints`) of the key whose signatures verified, `null` when none did or several keys signed; `signer_fingerprints` lists them. Before review r8 it hashed a `signatures/run.intoto.json.cert` member no bundle has, so it was always `null`. `run_id` is never the manifest's unsigned label. The ZIP is opened without following a link; a symlink, FIFO or other non-regular file returns 422, a missing bundle 404, a `manifest.json` over 64 MiB (or a run envelope over 16 MiB, or an archive over 1 GiB) 413. |
| `GET` | `/api/evidence/{bundle_id}/download` | Stream the bundle ZIP (`Content-Length` set). Read from a descriptor opened without following a link; a symlink, FIFO or other non-regular file returns 422 and no bytes, a missing bundle 404. |
| `POST` | `/api/evidence/{bundle_id}/verify` | Verify an evidence bundle with the same verifier as `nova verify <bundle>.zip`: every artifact digest, no missing, extra or duplicate member, `manifest_hash`, every attestation's DSSE signature, and each signed subject recomputed from the bundle's own content, plus the RFC 3161 token and the local NovaSeal log. The verifying key ships inside the bundle, so `valid: true` proves consistency, not origin. `signer_trust` is `anchored` only when every signer is in the operator's trust list (`NOVAFABRIC_EVIDENCE_TRUSTED_KEYS`, default `$NOVAFABRIC_HOME/evidence-trusted-keys.pem`); `unanchored` means no trust list is configured; `untrusted` (with `valid: false`) means a signer is not in it. A key in the request is never used. `run_ids` lists every run a verified run statement signs and `run_id` is that run when exactly one verified, else `null`; a manifest `subject`, label or top-level `run_id` that no verified statement signs fails the bundle. `members_signed` is true when the bundle's members statement (`attestations/bundle.intoto.json`) covers every member and the manifest's fields; then a rewritten, added or removed member fails `valid`. A bundle exported before that statement existed can be `valid` with `members_signed: false`, and `unsigned_members` names what no signature covers (always `README.md`, `schemas/` and `manifest.json`) — signed content only, never fully verified ([details](https://novafabric.ai/docs/architecture/sealing-and-verification/#every-member-is-signed-works-today-review-r8)). The local NovaSeal log is checked only for signed runs. An unusable configured trust list returns 500, a link or non-ZIP 422, a bundle over 1 GiB uncompressed (or a `manifest.json` over 64 MiB) 413. |
| `POST` | `/api/evidence/{run_id}` | export evidence endpoint |
| `POST` | `/api/evidence/{run_id}/bind` | Build criterion-evidence bindings for a capsule against a profile. |

## Compliance & governance  (27)

| Method | Path | Summary |
|---|---|---|
| `POST` | `/api/aibom/generate` | Generate CycloneDX AI-BOM(s) for one or all capsules — nova aibom generate. |
| `GET` | `/api/aibom/status` | Show CRA SBOM compliance status — nova aibom status. |
| `GET` | `/api/compliance/annex-iv` | Build and return an EU AI Act Annex IV document as JSON-LD. |
| `POST` | `/api/compliance/audit/bundle` | Export a compliance evidence bundle as base64-encoded ZIP (nova audit bundle). |
| `GET` | `/api/compliance/audit/coverage` | Per-control coverage analysis for the local capsule store (nova audit coverage). |
| `GET` | `/api/compliance/audit/map` | List evidence checkers for a compliance profile. |
| `POST` | `/api/compliance/audit/report` | Run compliance audit against a capsule and return a coverage report. |
| `POST` | `/api/compliance/audit/verify` | Validate the structure of an audit report JSON-LD (nova audit verify). |
| `POST` | `/api/compliance/erasure/request` | **Experimental — works today (ADR-0210).** (Makes PII unreadable only in capsules captured with `--pii-subject`, ADR-0314.) Execute a GDPR Art.17 erasure request through the real DEK destruction machinery (`DEKStore.erase_subject`, same code path as `nova pii erase`). Safe-mutations gated: body must carry `confirmed: true` (400 otherwise, nothing mutated). Persists the request PENDING in `$NOVAFABRIC_HOME/erasure.db`, executes synchronously, returns the terminal state (`COMPLETED`/`DEFERRED`/`FAILED`/`HELD`) with a receipt + `receipt_sha256`. **Legal holds win (GDPR Art.17(3)(e)):** while any legal hold is active the DEK is kept and the request ends `HELD` → 409 `{error: "legal_hold_active", erased: false}` with an `ErasureHeldReceipt` naming the holds and held capsules. Gated on crypto-shredding's own prerequisite: when the DEK store cannot be loaded → 409 `{error: "crypto_shred_unavailable", reason}`. `NOVA_CAP003_ENABLED` (cap-003, the dual-object split) does **not** gate it; `cap003_enabled` is reported for information only. |
| `GET` | `/api/compliance/erasure/status` | **Experimental — works today (ADR-0210).** List persisted GDPR erasure requests from `erasure.db`, newest first (`?subject_id=` exact filter, `?limit=`, default 200). Rows are hash-only at top level (`subject_sha256`); receipts appear verbatim. Empty queue returns `requests: []` honestly. |
| `POST` | `/api/compliance/euaiact/export` | Export EU AI Act Art.12 structured log records (ADR-0076). |
| `GET` | `/api/compliance/euaiact/status` | Return EU AI Act Art.12 compliance configuration (ADR-0076). |
| `POST` | `/api/compliance/examiner/{format}` | Export a capsule in examiner format (bagit / pccp / iso42001). |
| `POST` | `/api/compliance/export/aibom` | Export CycloneDX 1.7 AI-SBOM (ML-BOM) — nova export-aibom. |
| `POST` | `/api/compliance/export/c2pa` | Export C2PA v2.3 manifest for a capsule (ADR-0074 / EU AI Act Art.50). |
| `POST` | `/api/compliance/export/hipaa-proof` | Export HIPAA Safe Harbor de-identification proof — nova export-hipaa-proof. |
| `GET` | `/api/compliance/export/kinds` | The registry catalog the dynamic panel renders (ADR-0200 §2). |
| `POST` | `/api/compliance/export/nist-rmf` | Export NIST AI RMF 1.0 quantitative risk report — nova export-nist-rmf. |
| `POST` | `/api/compliance/export/rocrate` | Export a capsule as W3C RO-Crate v1.1 ZIP (base64-encoded). |
| `POST` | `/api/compliance/export/ropa` | Export GDPR Art.30 Records of Processing Activities (RoPA) — nova export-ropa. |
| `POST` | `/api/compliance/export/{kind}` | Run one registry exporter — the same builder its CLI command calls. |
| `GET` | `/api/compliance/nis2` | Build and return a NIS2 incident report as JSON. |
| `POST` | `/api/compliance/pii/erase` | Destroy a data subject's DEK — nova pii erase (**experimental**: makes PII unreadable only in capsules captured with `--pii-subject`, ADR-0314). While any legal hold is active the DEK is kept: 409 `{error: "legal_hold_active", erased: false, receipt}` (GDPR Art.17(3)(e)). Optional `retention_months` must be an integer in `0..1200` (default 6; `0` = no Art.17(3)(b) window, as `nova pii erase --retention-months 0`); a negative, non-integer, boolean or string value is **422** and no key is touched. |
| `GET` | `/api/compliance/subject-proof` | Return GDPR Art. 17 redaction proof for a data subject. |
| `GET` | `/api/governance/classify` | Classify an AI system risk tier inferred from a Run Capsule. |
| `POST` | `/api/governance/classify-manual` | Classify a manually described AI system — nova classify run. |
| `GET` | `/api/governance/vocabularies` | List vocabulary versions — nova classify list-vocabularies. |

### Generic compliance-export registry (works today, ADR-0200 §2)

`GET /api/compliance/export/kinds` returns the export-kind catalog (id, label,
required/optional fields) that the dashboard's dynamic export panel renders;
`POST /api/compliance/export/{kind}` runs the matching CLI-only exporter's own
builder function given a JSON body instead of CLI flags, and every call is
audit-logged with its `cli_equivalent`. This complements — and does not
replace — the bespoke literal routes above (`/api/compliance/export/aibom`,
`c2pa`, `hipaa-proof`, `nist-rmf`, `rocrate`, `ropa`), which stay in place;
the generic `{kind}` route is registered last so it never shadows them
(Starlette matches in registration order).

## Policy & approval  (8)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/policy/capture-level` | Return the current capture level, each tier's designed field allowlist (`tiers` — **not enforced**, `allowlist_enforced: false`), and per tier `enforcement.<level>.{enforced, effect}`: whether selecting it changes capture today (only `forensic` and `air_gapped`). DB-CAP-1, cap-004, ADR-0066 Am.1. |
| `POST` | `/api/policy/capture-level` | Validate a capture-level value and return restart instructions (DB-CAP-1). |
| `POST` | `/api/policy/check` | Evaluate a policy check interactively. Body: a `PolicyInput` plus optional `explain` and `policy_source`. `read` for the packaged policies; a non-blank `policy_source` needs `admin` (403 otherwise; 422 if it is not a string). `opa` runs sandboxed (see Authorization). |
| `GET` | `/api/policy/explain` | Look up a past policy decision from the audit log (nova policy explain). **`audit` scope.** `decision_id` is matched **exactly** against an entry's audit id or recorded decision id (no prefixes); an empty or wildcard id is **422**. Bounded O(page) tail read, **newest-first**; `limit` (default 100, max 1000) + byte-offset `cursor`; adds `next_cursor`/`truncated` (ADR-0199 B2). |
| `GET` | `/api/policy/list` | List Rego bundle files and signed promotion policies — nova policy list. |
| `GET` | `/api/policy/recent-decisions` | Return recent decision IDs from the audit log for autocomplete. Bounded O(page) tail read; byte-offset `cursor`; adds `next_cursor`/`truncated` (ADR-0199 B2). |
| `POST` | `/api/policy/sign` | Sign and store a new promotion policy — nova policy sign. |
| `POST` | `/api/policy/test` | Run the Rego test suite for the policy bundle (nova policy test). `read` for the packaged bundle; a `bundle_path` needs `admin` and is confined to the read roots. `opa test` runs sandboxed. |

## Sealing & trust  (10)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/seal/log/verify` | Verify internal consistency of the local Merkle log (ADR-0041). The log (`NOVAFABRIC_SEAL_DB_PATH`, `merkle_db`, then `$NOVAFABRIC_HOME/novaseal-merkle.db`) is opened read-only: nothing is created or migrated. A cleanly closed log is opened with SQLite's `immutable=1`, so not even its `-wal`/`-shm` side files are created; while a writer is connected (side files present) the open uses normal locking, reads every committed leaf and adds no file (review r8). 404 when no log exists, 501 for a Postgres log (use `nova seal log verify`; the DSN is shown without its password), 422 for a file that is not a NovaSeal log. `capsule_included` (with `capsule_id`, at most 256 characters) is an exact `capsule_id` match, never a pattern. |
| `GET` | `/api/seal/policy` | Return the latest promotion policy predicate. |
| `POST` | `/api/seal/ratchet/init` | Provision epoch-0 ratchet state for a node. |
| `POST` | `/api/seal/ratchet/rotate` | Advance to the next signing epoch; erase the previous chain key. |
| `GET` | `/api/seal/ratchet/status` | Show a node's current signing epoch and registry history. |
| `POST` | `/api/seal/sigstore/sign` | Sign a capsule artifact using Sigstore keyless signing (ADR-0071). |
| `POST` | `/api/seal/sigstore/verify` | Verify a stored Sigstore bundle for a capsule (ADR-0071). |
| `POST` | `/api/seal/{capsule_id}/bypass` | Create a time-limited SoD bypass, DSSE-signed and permanently logged (ADR-0059). |
| `GET` | `/api/seal/{capsule_id}/proposals` | List all proposals for a capsule with their approval status. |
| `POST` | `/api/seal/{capsule_id}/verify` | Run the five-check SoD verifier for a capsule's promote bundles. |

Both routes bind to the served capsule's content (review cli-b-01): a bypass signs the SHA-256 of its `capsule.yaml` (an unknown capsule answers **404**, nothing is signed), and verify recomputes it — a proposal or bypass signed for other content, or a capsule that no longer exists, fails with `exit_code: 10`; the response carries `content_checked: true`.

`POST /api/seal/{capsule_id}/bypass` fails closed: the bypass is stored only after its `seal.bypass.create` entry (actor `serve:<token fingerprint>`) is in the hash-chained audit log. If that entry cannot be written, the route answers **503** and stores nothing. If the entry was written but the bypass file then cannot be stored (disk full, I/O error), a compensating `seal.bypass.abort` entry for the same `bypass_uuid` withdraws it and the route answers **503** (review r8; it was a raw 500 with no dashboard record). Every attempt that reaches the store also writes a `seal_bypass` record to the dashboard audit log. A stored bypass counts in verification only while the audit log holds its `seal.bypass.create` entry and no `seal.bypass.abort` ([NovaSeal configuration §4.1](https://novafabric.ai/docs/novaseal-configuration/#41-maker-checker-bypass-notifications)). The response adds `notification_status` (`pending` or `not_configured`) and `notification_sent_to`, the channel configuration the signed predicate records; the delivery outcome is the `seal.bypass.notify` audit entry ([NovaSeal configuration §4.1](https://novafabric.ai/docs/novaseal-configuration/#41-maker-checker-bypass-notifications)).

## Knowledge graph  (18)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/kg/agents/{agent_id}/edges` | Return models, tools, and MCP servers called by an agent (DB-KG-1). |
| `GET` | `/api/kg/aliases` | List all alias-table entries, optionally filtered by canonical entity id. |
| `POST` | `/api/kg/aliases` | Register (upsert) an alias into the Tier-2 alias table. |
| `POST` | `/api/kg/attack-path` | Shortest attack path between two entities — mirrors `nova kg attack-path` (UC2). |
| `POST` | `/api/kg/blast-radius` | Impact/blast-radius of an entity — mirrors `nova kg blast-radius` (UC3). |
| `POST` | `/api/kg/detect` | Unsupervised SPKG anomaly scan of a capsule — mirrors `nova kg detect` (ADR-0111). |
| `GET` | `/api/kg/entity-queue` | Return pending ReviewItems from the Tier-3 human review queue (bounded: `limit` default 200, max 1000; adds true `total` + `truncated`). |
| `GET` | `/api/kg/entity-queue/stats` | Return pending/approved/rejected counts for the entity review queue. |
| `POST` | `/api/kg/entity-queue/{item_id}/approve` | Approve a review item.  Body: {canonical: str, resolved_by: str}. |
| `POST` | `/api/kg/entity-queue/{item_id}/reject` | Reject a review item.  Body: {resolved_by: str}. |
| `POST` | `/api/kg/ingest` | Ingest a capsule directory into the KG — mirrors `nova kg ingest`. |
| `POST` | `/api/kg/ingest-all` | Bulk-ingest all capsule directories into the KG. |
| `POST` | `/api/kg/init` | Initialise the KG schema (idempotent) — mirrors `nova kg init`. |
| `GET` | `/api/kg/status` | Return Capsule KG store health + entity counts (DB-KG-1, ADR-0067). |
| `GET` | `/api/kg/topology` | Return all KG nodes and edges for multi-layer topology visualization. |
| `GET` | `/v1/kg/audit` | KG health audit: node/edge counts, zero-call-count edges. |
| `GET` | `/v1/kg/query` | Query models and tools for an agent.  Mirrors `nova kg query`. |
| `GET` | `/v1/kg/status` | KG store health check (Tier 2+ aware).  Alias of /api/kg/status. |

## Assurance & MCP  (3)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/assure/{run_id}` | Run OWASP Top 10 for LLM evidence checks against a capsule. |
| `POST` | `/api/mcp/risk-report` | Generate a structured OWASP LLM risk report for an MCP manifest. |
| `POST` | `/api/mcp/scan` | Scan an MCP server manifest for OWASP LLM supply-chain risks. |

## Storage & infrastructure  (7)

| Method | Path | Summary |
|---|---|---|
| `POST` | `/api/db/upgrade` | Run alembic upgrade to the specified revision (default: head). |
| `GET` | `/api/infra/backups` | List backup archives in `NOVA_BACKUP_DIR` (manifest-claimed, not hash-verified — that's `nova backup verify`); `{detected: false}` when unconfigured (ADR-0201 P7). |
| `GET` | `/api/infra/collector` | Return collector health. Returns {detected: false} if not running. |
| `GET` | `/api/storage/inspect/{run_id}` | Show dual-object split for a run (DB-STG-1, cap-003). |
| `GET` | `/api/storage/manifest-chain` | Return the last N entries in the manifest chain. |
| `GET` | `/api/storage/stats` | Return object capsule store statistics. |
| `GET` | `/api/storage/validate` | Validate S3 backend supports Object Lock COMPLIANCE (DB-STG-1, cap-009). |

## Cost & metrics  (7)

| Method | Path | Summary |
|---|---|---|
| `POST` | `/api/cost/attribute` | Compute productive-vs-wasted spend attribution over a request document — same core as `nova cost attribute` (ADR-0146 D3). |
| `POST` | `/api/cost/fairness` | Compute per-agent spend share + Gini fairness report — same core as `nova cost fairness` (ADR-0146 D5). |
| `GET` | `/api/cost/pricing` | Return the per-1k-token price table from CostInterceptor (DB-COST-1). |
| `GET` | `/api/cost/report` | Return aggregated LLM cost (DB-COST-1 / cap-002). |
| `POST` | `/api/cost/usage-breakdown` | Compute token usage-type composition — same core as `nova cost usage-breakdown` (ADR-0132). |
| `GET` | `/metrics` | metrics endpoint |
| `GET` | `/metrics/stream` | SSE metrics stream (FR-21: Last-Event-ID reconnect support). |

The three `/api/cost/*` POST routes above are pure, deterministic, read-only
wraps of the same cost cores their `nova cost` CLI counterparts call (no
capsule I/O, no writes, not audit-logged — computing a bounded aggregate over
caller-supplied numbers isn't a mutating or boundary-crossing action).

## Reports  (17)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/report` | Generate an asset inventory report — mirrors `nova report`. |
| `GET` | `/api/reports/alert-digest` | Registry-driven data route (R4) — `{columns, rows, count}` JSON or CSV. |
| `GET` | `/api/reports/api-key-inventory` | Registry-driven data route (R4) — `{columns, rows, count}` JSON or CSV. |
| `GET` | `/api/reports/capsule-compare` | report capsule compare |
| `GET` | `/api/reports/catalog` | Return the report registry (identity, filters, chart specs) — the single source of truth the dashboard ReportsTab reads for preview charts (ADR-0200/0201). |
| `GET` | `/api/reports/compliance-posture` | Registry-driven data route (R4) — `{columns, rows, count}` JSON or CSV. |
| `GET` | `/api/reports/cost-burn` | report cost burn |
| `GET` | `/api/reports/dashboard-audit` | Registry-driven data route (R4) — `{columns, rows, count}` JSON or CSV. |
| `GET` | `/api/reports/eval-regression` | report eval regression |
| `GET` | `/api/reports/evidence-inventory` | report evidence inventory |
| `GET` | `/api/reports/executive-summary` | report executive summary |
| `GET` | `/api/reports/policy-audit` | report policy audit |
| `GET` | `/api/reports/release-comparison` | report release comparison |
| `GET` | `/api/reports/run-history` | report run history |
| `GET` | `/api/reports/seal-verification` | report seal verification |
| `GET` | `/api/reports/throughput` | report throughput |
| `GET` | `/api/reports/{report_id}/export?format=html\|pdf` | Export a self-contained report artifact with an inline-SVG chart where the registry declares one (ADR-0201: server-side chart rendering). HTML needs nothing; PDF lazily imports the optional WeasyPrint extra and degrades to `501` + install hint when absent (`pip install 'novafabric[compliance]'`). Scope is the exported report's own (`audit` for `dashboard-audit`/`policy-audit`, `admin` for `api-key-inventory` and unknown ids, otherwise `read`). |

## Legal holds  (3)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/holds` | list holds |
| `POST` | `/api/holds` | create hold |
| `POST` | `/api/holds/{hold_id}/release` | release hold |

## Schema  (1)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/schema/list` | List the CapsuleEventType values + meta (DB-SCH-1, cap-001 / ADR-0066; |

## Topology (live)  (5)

| Method | Path | Summary |
|---|---|---|
| `POST` | `/api/topology/seed` | Seed the live topology store from capsules already on disk. |
| `GET` | `/api/topology/snapshot` | Return current topology counts for the SPA status bar. |
| `GET` | `/topology/cluster-edges` | Return inter-cluster edge aggregates for drawing edges between super-nodes. |
| `GET` | `/topology/cluster-list` | Return clusters as plain JSON rows (largest first) for the Table/Treemap views. |
| `GET` | `/topology/clusters` | Return Arrow IPC cluster layer (ADS v1 cluster_layer schema). |

## Admin  (11)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/admin/api-keys` | Read-only, secret-free API-key projection (ADR-0193). Bounded: `limit` (default 500, max 2000) pushed into SQL; `total` is a true `COUNT(*)`; adds `truncated` (ADR-0199 B2). |
| `POST` | `/api/admin/flush-jwks-cache` | Flush the JWKS cache, forcing a re-fetch from the OIDC provider. |
| `GET` | `/api/admin/new-run-id` | Generate a fresh ULID for use as NOVAFABRIC_GLOBAL_RUN_ID (cap-007 FR-27). |
| `POST` | `/api/admin/rebuild-metadata-db` | Disaster-recovery rebuild of the metadata DB from the chain log. |
| `POST` | `/api/admin/reindex-runs` | Confirm-gated (`{"confirmed": true}`, else 400), idempotent INSERT-OR-REPLACE rebuild of the `runs_cache` index from the capsule filesystem — never deletes a capsule or a row with a still-existing capsule (ADR-0201 P8). Optional `{"prune": true}` (default false) also drops rows whose capsule directory is gone; response gains `pruned`. |
| `GET` | `/api/admin/roles` | List role assignments (local mode). |
| `POST` | `/api/admin/roles` | Assign a role to a subject (idempotent). Local-mode admin shortcut. |
| `DELETE` | `/api/admin/roles/{subject}/{role}` | Revoke a role from a subject. 404 if not found, 409 if lockout would occur. |
| `GET` | `/api/admin/tokens` | List issued local tokens (stored in ~/.novafabric/tokens.jsonl), newest-first. Each row reports the scope it is enforced with: a record written before ADR-0228 has none and is enforced as `admin`; a record whose `scope` is not recognised is reported as `none` and holds no scope. Bounded: `limit` (default 200, max 2000) + byte-offset `cursor`; adds `next_cursor`/`truncated` (ADR-0199 B2). Requires `admin` scope. |
| `POST` | `/api/admin/tokens` | Issue a new local session token. The token is returned **once** and stored only as a `sha256:` digest (`0600`); it cannot be recovered from the file. It authenticates by `Authorization: Bearer` or `?token=`. Optional `scope` — `read`, `operate`, `admin` (default) or `audit` (ADR-0228) — bounds what it may reach; the response echoes the scope it minted. Omitting it reproduces the pre-ADR-0228 behaviour, **the same full access as the dashboard token**. Requires `admin` scope. |
| `DELETE` | `/api/admin/tokens/{fingerprint}` | Revoke a token by fingerprint. Revocation takes effect immediately: the next request with that token gets 401. |

## alerts  (1)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/alerts/recent` | recent alerts |

## analytics  (1)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/analytics/summary` | analytics summary |

## incidents  (5)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/incidents` | List the newest incidents with their nearest Art. 73 deadline. |
| `POST` | `/api/incidents` | Open a new incident (forward-only lifecycle; never deleted). |
| `GET` | `/api/incidents/{incident_id}` | Get one incident with computed Art. 73 deadlines. |
| `GET` | `/api/incidents/{incident_id}/export` | Export an incident as an OECD-AIM or NIS2 structured report. |
| `POST` | `/api/incidents/{incident_id}/transition` | Advance an incident's lifecycle (open → reported → closed). |

## ingest-capsule  (1)

| Method | Path | Summary |
|---|---|---|
| `POST` | `/api/ingest-capsule` | Index capsule(s) into the runs metadata store — nova ingest-capsule. |

The route indexes without opening a file watcher. Before 2026-10-23 each request started an inotify instance and watcher threads that were never closed, so about 128 requests could exhaust the user's inotify instances.

## lineage-store  (1)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/lineage-store/profile` | Generate a lineage-store deployment profile — nova lineage-store profile. |

## livez  (1)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/livez` | livez |

## ops  (1)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/api/ops/daemon-status` | Report whether the warm capture daemon socket is alive (read-only). |

## query  (1)

| Method | Path | Summary |
|---|---|---|
| `POST` | `/api/query` | Execute a Capsule Query DSL document (`{q, engine?}`) — the same JSON/YAML query object `nova query --query-file` accepts, not a free-text/SQL string (ADR-0129, no new grammar). Read-only, capped at 5000 rows regardless of the plan's own `limit`; a 422 on any parse/allow-list failure, a 500 only on a malformed on-disk capsule index. |

## readyz  (1)

| Method | Path | Summary |
|---|---|---|
| `GET` | `/readyz` | readyz |

## Deprecation register (ADR-0188)

**Maturity:** mechanism, CI drift gate and CI removal gate work today (experimental);
the register is empty. Policy and machinery:
[ADR-0188](https://novafabric.ai/docs/decisions/).

**Scope.** The lifecycle policy applies to the multi-user `nova server` API only
(`api/openapi.yaml`, `/v0` and later). The `nova serve` dashboard API documented in the
tables above remains **experimental/unversioned** — no lifecycle promises — until the
planned ADR-0183 consolidation.

**Breaking-change definition (normative).** Breaking: removing or renaming a path,
query parameter, or response field; narrowing a type or enum; tightening auth or
required scopes on an existing endpoint; changing error-envelope semantics (codes,
envelope shape). Non-breaking: adding optional fields, endpoints, enum values
documented as open, or response headers. If it is not on the breaking list, it may
ship in any release; if it is, the lifecycle below is mandatory.

**Lifecycle.** A deprecation is announced in `CHANGELOG.md`, the release notes, and
this register, all at once. From the deprecating release onward the endpoint emits
three headers on every response (implemented in
`src/novafabric/server/deprecation.py`):

| Header | Format | Meaning |
|---|---|---|
| `Deprecation:` | `@<unix-timestamp>` or `true` (RFC 9745) | The endpoint is deprecated (and since when, if known). |
| `Sunset:` | HTTP-date (RFC 8594) | Earliest date the endpoint may be removed. |
| `Link:` | `<url>; rel="deprecation"` | Points at this register's entry for the endpoint. |

**Minimum window.** A deprecated endpoint stays working for **at least two minor
releases**. Removal lands only in a **minor** version bump pre-1.0 and only in a
**major** bump post-1.0. No silent removals, ever. Every register row names the
deprecating release, an earliest-removal release at least two minors later, and a
replacement (or an explicit "none"); each row must match a `deprecated: true`
operation in `api/openapi.yaml` (drift gate, `tests/test_deprecation_drift_gate.py`).

**Removal gate (works today, experimental).** `tests/test_deprecation_removal_gate.py`
fails CI when this register breaks the lifecycle rules (pure checks in
`src/novafabric/server/deprecation_gate.py`):

- every row names a deprecation release, an earliest-removal release at least two
  minors later (`earliest ≥ major.(minor+2).0` of the deprecation release), and a
  replacement or `none`;
- a register-listed endpoint that is no longer served (neither mounted by the default
  `nova server` app nor declared in `api/openapi.yaml`) must keep its row and record
  the removing release in **Removed in**; that release must be ≥ the earliest-removal
  release, ≤ the current package version (`novafabric.__version__`), and a legal
  removal point — a minor `0.Y.0` pre-1.0, a major `X.0.0` post-1.0;
- a row that records **Removed in** while the endpoint is still served fails.

Removed rows stay in the table as the audit trail and are excluded from the drift
gate (they no longer carry `deprecated: true` in `api/openapi.yaml`).

### Register

*No endpoints are currently deprecated.*

| Endpoint | Deprecated in | Earliest removal | Sunset date | Replacement | Removed in |
|---|---|---|---|---|---|
| — | — | — | — | — | — |

## Interactive API docs

When the server is running, the same route table is browsable interactively:

- Swagger UI: `http://127.0.0.1:4321/api/docs?token=<token>`
- OpenAPI JSON: `http://127.0.0.1:4321/api/openapi.json`

## Summary & next steps

- This document covers the **local, experimental** `nova serve --experimental` HTTP surface —
  a read-first (Layer A) dashboard over capsules on your own machine. The separate
  multi-tenant `nova server` API is specified in `api/openapi.yaml`.
- **`api/openapi.yaml` is a hand-curated spec, not a generated one — but is now essentially
  complete.** As of this pass it documents 76 operations, matching all 76 always-mounted
  `nova server` routes (the resource routers under `/v0` plus the `/scim/v2/*` surface;
  previously-noted gaps — `/v0/orgs`, `/v0/workspaces`, `/v0/service-accounts`,
  `/v0/admin/roles`, `/v0/runs/suggest-register`, `/v0/seal/*`, `/v0/auth/saml/*`, SCIM — are
  all present now). The one real remaining gap: the RFC 8628 device-authorization-grant demo
  trio (`POST /v0/auth/device/code`, `POST /v0/auth/token`, `POST /v0/auth/approve`,
  `src/novafabric/server/routes/auth.py`) is absent from the spec. That's arguably correct
  scoping rather than drift — those three are unauthenticated demo scaffolding mounted only
  when an operator opts in (`config.demo_device_grant`, default `False`; the HS256 tokens
  they issue are never honored by the real verifier) — but flagging it here rather than
  silently dropping it from this note.
- **Usage chargeback export (experimental, ADR-0208 P3):** `GET /v0/usage/export?from=YYYY-MM&to=YYYY-MM&format=csv|ndjson`
  (optional `workspace=` / `org=`) on the `nova server` API streams the same rows as
  `nova server usage export` as an attachment — `text/csv; charset=utf-8; header=present`
  (RFC 4180, formula-injection-safe cells) or `application/x-ndjson`, filename
  `nova-usage-<from>_<to>.<ext>`. Admin/auditor see every workspace; any other principal only
  its membership workspaces (filtered, not 403). Bad period/format or a range wider than 120
  periods → `400` error envelope (`invalid_period` / `invalid_period_range` /
  `invalid_format`). Read-only (registry opened `mode=ro`, membership lookup included); a
  locked/unreadable registry → `503` (`usage_store_unavailable`), never an empty 200; not
  audited.
- Requests to `/api/*` require the **session token**, in either of two forms (v0.97.0):
  a `?token=<token>` query parameter, or an `Authorization: Bearer <token>` header —
  **when the header carries a Bearer credential it is authoritative** and the query
  parameter is ignored (an absent or non-Bearer header falls back to `?token=`).
  The query form is kept for the SPA and for printed/bookmarked URLs; prefer the header
  for scripts and `curl`, so the secret stays out of shell history, proxy logs, and
  `Referer`. The seven `public` routes are open: the probes `GET /api/health`, `GET /livez`
  and `GET /readyz`, the interactive docs (`GET /api/docs`, `GET /api/openapi.json`,
  `GET /docs/oauth2-redirect`) and the no-dashboard placeholder `GET /` (`/metrics` requires
  the token). The server binds to `127.0.0.1` and rejects
  non-localhost `Host` headers.
- The endpoint tables are **generated** — after any route change a maintainer
  re-runs the generator so this file stays truthful. The generator lives in the
  maintainers' private `design/` tree and is not published, so a contributor
  cannot regenerate this page; note the drift in a PR and it will be regenerated.
- A route existing in the table is not by itself a claim that everything behind it is
  fully shipped — several endpoints are explicitly marked **experimental** inline above
  (e.g. the ADR-0204 content-search `scope` parameter, the ADR-0210 GDPR erasure pair,
  the OTLP ingest endpoint); anything not so marked is **works today**, per this repo's
  docs-honesty convention (works today / experimental / planned / future design).

Where to go next:

- **CLI reference** (`docs/cli-reference.md`) — the canonical, offline interface; every
  dashboard view maps to a `nova` command.
- **Developer guide** (`docs/developer-guide.md`) — adding asset types, CLI commands, and
  report formats.
- **`nova serve --help`** — the authoritative, always-current list of server flags.
