---
title: "Developer Guide"
description: "Contributor guide to the NovaFabric codebase: local setup, quality gates, and how to add a CLI command, API endpoint, framework adapter or report format."
canonical: https://novafabric.ai/docs/developer-guide/
last_updated: 2026-10-25
source: https://github.com/MSKazemi/novafabric/blob/main/docs/developer-guide.md
---

# Developer Guide

This guide is for **contributors working inside the NovaFabric tree** — not end users.
It documents how to build, test, and extend the code that produces NovaFabric's two
top-level artifacts (the **Run Capsule** and the **Evidence Bundle**) and the five
primitives around them (Asset Registry, Run Capsule, Replay, Lineage, Evidence Bundle).

If you are *using* the `nova` CLI rather than modifying it, start with
[`docs/cli-reference.md`](https://novafabric.ai/docs/cli-reference/) instead.

## What you will learn

- How to set up a local dev environment and run the quality gates (tests, ruff, mypy).
- The repeatable extension patterns for the parts of the codebase you are most likely
  to touch: **asset types, CLI commands, `nova serve` endpoints, report formats, and
  framework adapters**.
- How to work on the internal subsystems that back the trust and lineage layers —
  the NovaSeal signing code, the maker-checker `promote` package, the Event Envelope
  wire format, the standard outer envelopes, and the Security & Provenance Knowledge
  Graph (SPKG).
- The extension points (protocols and entry-point groups) that let you plug in signing
  backends, watcher backends, eval suites, and lineage backends without forking core.

> **Maturity note.** NovaFabric follows a strict docs-honesty rule: every feature is
> either *works today*, *experimental*, *planned*, or *future design*. Many subsystems
> described here (NovaSeal, the Go collector tier, topology dashboards, object capsule
> store) are **in-tree engineering work toward capabilities that are not part of the
> released local-first product surface**. Sections that document such work are marked
> **experimental** or reference the ADR that tracks their design intent. When a section
> is unmarked, it covers a shipped surface. Never describe an experimental or planned
> subsystem as a stable product feature in user-facing docs — see
> [the docs-honesty rule](#docs-honesty-and-maturity-labels) below.

## Contents

- [Local installation](#local-installation)
- [Quality gates](#quality-gates) — tests, ruff, mypy
- [The PyPI README (build-time link rewrite)](#the-pypi-readme-build-time-link-rewrite)
- **Common extension patterns**
  - [Adding a new asset type](#adding-a-new-asset-type)
  - [Adding a new CLI command](#adding-a-new-cli-command)
  - [Adding a new dashboard tab or input](#adding-a-new-dashboard-tab-or-input)
  - [Adding a new `nova serve` API endpoint](#adding-a-new-nova-serve-api-endpoint)
  - [Adding a new report format](#adding-a-new-report-format)
  - [Extending failure attribution](#extending-failure-attribution-diagnose-adr-0084)
  - [Changing what mocked replay serves](#changing-what-mocked-replay-serves-adr-0300-adr-0304)
  - [Adding a framework adapter](#adding-a-framework-adapter)
  - [Adding a compliance audit profile](#adding-a-compliance-audit-profile)
  - [Adding a compliance exporter](#compliance-exporters--adding-a-new-format)
- **Trust and provenance subsystems**
  - [Working with NovaSeal](#working-with-novaseal-trustnovaseal) *(experimental)*
  - [Working with the Promote package](#working-with-the-promote-package-promote)
  - [Working with EventEnvelope](#working-with-eventenvelope-envelope)
  - [Standard outer envelopes](#standard-outer-envelopes-envelopes--experimental) *(experimental)*
  - [Working with the SPKG](#working-with-the-spkg-kgspkg--experimental) *(experimental)*
  - [Standalone trust primitives (Python-API only)](#standalone-trust-primitives-python-api-only) *(experimental)*
- **Scale-out and topology work** *(engineering toward planned architecture)*
  - [Building the collector tier](#building-the-collector-tier-go-phase-2)
  - [Live Topology Dashboard development](#live-topology-dashboard-development)
  - [TV-5 3D Topology View development](#tv-5-3d-topology-view-development)
  - [Warm capture daemon development](#warm-capture-daemon-development-daemon-adr-0092)
- **Pluggable extension points**
  - [Signing / KMS, notifier, compression, lineage, and spool backends](#extension-points)
  - [Extending the Capsule Knowledge Graph](#extending-the-capsule-knowledge-graph)
  - [Extending CapsuleWatcher backends](#extending-capsulewatcher-backends)
  - [Registering a third-party eval suite adapter](#registering-a-third-party-eval-suite-adapter)
  - [Writing a custom PII masker](#writing-a-custom-pii-masker-adr-0135--experimental) *(experimental)*
  - [Querying and extending PolicyStore](#querying-and-extending-policystore)
- [Docs-honesty and maturity labels](#docs-honesty-and-maturity-labels)
- [Where to go next](#where-to-go-next)

---

## Local installation

Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).

```bash
git clone git@github.com:MSKazemi/novafabric.git
cd novafabric
uv sync --all-extras   # a plain `uv sync` / `--dev` leaves out extras the test suite imports
```

The `novafabric` CLI is then available as:

```bash
uv run novafabric --help
# or with the venv activated:
source .venv/bin/activate
novafabric --help
```

### Dependency tiers (ADR-0222)

`[project.dependencies]` is the **default install** — keep it small, and keep it
matching what a plain `pip install novafabric` genuinely needs. As of v0.99.0 it
is 113 MB / 42 packages; `duckdb`, `pyarrow`, `python-louvain` and
`clickhouse-connect` live in extras because every one of their import sites is
already behind an extra.

Two rules bind new code:

1. **Do not add a top-level `import duckdb` / `import pyarrow` /
   `import community` / `import clickhouse_connect` to a core code path.**
   `tests/packaging_metadata/test_lean_install_surface.py` pins the exact set of modules
   allowed to need those, and fails on anything new. Prefer a function-level
   (lazy) import.
2. **Honour the degradation contract.** Any core-reachable use of an
   extras-only dependency must either fall back to a stdlib-equivalent path with
   identical results, or raise an `ImportError` naming the exact extra
   (`pip install novafabric[<extra>]`). Never a silent wrong answer, never a
   bare `ModuleNotFoundError`. See
   `cost/clickhouse_store.py::require_clickhouse_connect` for the house pattern,
   and `tests/packaging_metadata/test_optional_dependency_degradation.py` for how each
   clause is tested.

`networkx` stays in core: the CLI imports it eagerly at start-up and `nova
insights` / `nova lineage` depend on it. Note `nova insights` uses networkx's
own `nx.community.louvain_communities`, *not* the `python-louvain`
distribution (`import community`) that moved to `[serve]`.

The dev venv installs everything (`uv sync --all-extras`), so a missing-extra
bug will not reproduce locally — that is exactly why the two packaging test
modules simulate a lean install with a `sys.meta_path` blocker.

### First-run setup (pip install path)

After installing from source or PyPI, run `nova init` once to create the
data directories and signing keypair:

```bash
nova init                  # creates ~/.novafabric/{capsules,keys,replays}
                           # and ~/.novafabric/keys/signing_key.pem (mode 600)
nova init --force          # regenerate keypair (old pair archived to keys/archive/)
nova seal init             # optional: local, self-asserted capsule sealing (ADR-0301)
nova init --home /data/nova  # custom NOVAFABRIC_HOME
```

This is not needed for docker-compose deployments — `make dev-up` handles
all first-boot setup inside the container.

### Docker-compose deployment targets

**Works today.** The self-hosted stack is driven by a small set of `make`
targets (all use `deploy/docker/docker-compose.yml`; the container name
`novafabric-serve` is fixed, so every target manages the same stack and Postgres
volume). After a healthy start each target prints the dashboard URL with a live
token.

| Target | What it does |
|--------|--------------|
| `make dev-up` | Build + start Postgres and `novafabric-serve` only (fast). |
| `make prod-up` | Full stack: + ClickHouse, NATS, Kafka, PgBouncer, JanusGraph. |
| `make update` | `git pull` → rebuild the `nova` image → rolling restart (databases untouched). Use for a normal release deploy from the tracked branch. |
| `make deploy-local` | Build + run `novafabric-serve` straight from the **current working tree** — no `git pull`, no remote round-trip. Stamps the deployed commit (`+ -dirty` when the tree has uncommitted changes) so "what's running" stays answerable. The fast dev-iteration path. |

A repo-root `.dockerignore` keeps the build context lean (it excludes `.git`,
`.venv` and `node_modules`), so source-tree rebuilds stay fast.

> **Deploy checkout tip:** point the deploy checkout's `origin` at the
> authoritative repo you actually commit to, so `make update` fast-forwards
> cleanly. A mirror whose history is force-regenerated will diverge and force a
> manual reset each time.

---

## Quality gates

Run all four gates before opening a PR. Coverage must stay **≥ 90%**, and ruff, mypy
and the documentation link check must be clean. These are the same commands enforced
in CI (see also `CONTRIBUTING.md`).

```bash
make test-fast     # tests (~4 min measured; the old "~90 s" was stale)
make lint          # ruff check src tests scripts
make typecheck     # mypy src
make check-links   # every relative link in a public doc resolves
```

`make check-links` is not a style gate. It rejects a link whose target is **not
tracked by the public git**, which is a different question from whether the file
exists — the maintainers keep one working tree and two gits, so their private `design/`,
`.claude/`, `CLAUDE.md` and `THREAT_MODEL.md` are present on a maintainer's disk and
absent for every reader of the published repository. Checking existence passes
locally and fails only for the person the docs are written for.

### How the test tiers work

The suite is **~20,000 tests** (20,199 collected on 2026-10-22), so "run the tests" is not one command — it is a ladder, and each
rung is defined by what it can prove and what it costs.

| Tier | Command | Scope | Measured |
|---|---|---|---|
| 0a | *(automatic)* | lint the file you just edited | ~0.2 s |
| 0b | `make test-direct` | tests directly covering your change | **~17 s** |
| 1 | `make test-impacted` | full static import closure of your change | seconds–minutes |
| 2 | `make test-fast` | everything except Docker and `tests/integration` | ~4 min |
| 2 | `make test-gate` | tier 2 + the push-gate stamp — run it *before* pushing | ~4 min, then pushes are instant |
| 3 | `make test-par` | the release gate — the whole suite as CI's `unit` shards run it, coverage ≥ 90% | ~5 min |
| — | `make test-container` | the Docker tier alone | needs a daemon |
| — | `make test-shard SHARD=3/6` | exactly one CI `unit` shard — to reproduce a red shard | a sixth of tier 3 |

**Tiers 0 and 2 can run by themselves.** The three hook scripts are public, under
`scripts/hooks/`: `lint-edited-file.sh` lints each edited file, `stop-scoped-tests.sh` runs tier
0b once a batch of edits is done, and `pre-push-test-gate.sh` runs tier 2 and refuses to push red.
The maintainers wire the first two into their editor tooling (a Claude Code `PostToolUse` and
`Stop` hook — that local configuration is not part of this repository) and the third as the git
`pre-push` hook. To get the same push gate, link it yourself:
`ln -s ../../scripts/hooks/pre-push-test-gate.sh .git/hooks/pre-push`. Without any hook, run
`make test-direct` while you work and `make test-gate` before you push.
Design and rationale: ADR-0267.

**Every tier is load-aware.** `-n auto` would otherwise claim *all* cores per run, and several
concurrent sessions each doing that has been measured to push this class of machine to load 49–65
on 20 cores with 11 GiB in swap. `scripts/test-workers.sh` sizes the worker count to the cores and
memory that are actually free (never below 1, never above `nproc`), and the Makefile exports it as
`PYTEST_XDIST_AUTO_NUM_WORKERS`, which pytest-xdist reads as the value of `auto` — the pytest
command lines are untouched, so `test-par` still runs what CI runs. Automated runs are
additionally `nice`d, the Stop hook skips (without blocking) when the machine is already saturated,
and concurrent scoped runs serialize on a lock instead of stacking. Force a count with
`NOVA_TEST_WORKERS=8 make test-fast`. Guard: `tests/docs/test_worker_throttle.py`.

**Run the push gate by hand, then push.** git opens the SSH connection to the remote *before*
running `pre-push`, so a long in-hook gate can outlive it — measured 2026-09-04: a 34-minute
gate run under load ended with GitHub closing the connection and a *green* gate failing to push
(SIGPIPE, exit 141). `make test-gate` runs the same gate (same script — `scripts/test-gate.sh`,
which the hook itself calls, so the two can never drift) and stamps the result; the push that
follows sees the stamp and completes in seconds. Because a person is waiting on it, the gate
gets a floor of half the cores even on a loaded box (`--gate` mode of `test-workers.sh`),
still memory-capped and `nice`d.

**Hooks never touch the uv project lock.** `uv run` serializes every invocation on one
project-wide lock; with many concurrent sessions the per-edit lint hook and the scoped test
runs would queue on it (the documented deadlock signature: zero CPU across all siblings). The
hooks call `.venv/bin/ruff`, `.venv/bin/python`, `.venv/bin/pytest` directly, falling back to
`uv run` only when the venv does not exist yet.

**How selection works.** `scripts/testsel.py` builds the import graph with `ast` — it parses,
it never executes — and maps changed files to test files. The governing rule is that
**ambiguity selects more, never less**: a changed `pyproject.toml`, `uv.lock`, `Makefile` or
root `conftest.py` escalates to the whole suite, and so does anything it cannot parse or scope.

**What tiers 0 and 1 cannot see.** Static analysis cannot follow a string-referenced entrypoint,
a plugin registered by name, or a runtime `importlib` call. A green tier 0 is a fast pre-check,
**not** evidence that a change is safe to push — that is what tier 2 and CI are for.

> ⚠ `make test-changed` (pytest-testmon) is **disabled** and the target fails loudly. Measured
> 2026-09-01, it took **over 10 minutes** on both a cold and a warm index: testmon cannot run
> under `pytest-xdist`, so its baseline is a *serial* run of the whole suite. It had been
> documented as "sub-second on an unchanged tree", which stopped being true as the suite grew.

Rebuild the selector index by hand after a large refactor (it is rebuilt automatically when
missing, and takes ~2.5 s):

```bash
make test-index
```

### How CI runs the suite (ADR-0316)

CI does not run the suite as one job. The `unit` job is a matrix of **six shards**, each
running a disjoint set of test *files* with `pytest --shard=I/6`:

- **Balanced by measured time.** `tests/_shard.py` places files largest-first onto the lightest
  shard, weighted by `tests/.test-durations.json` (per-file seconds from CI's JUnit reports). A
  file with no recorded time is weighted by its test count, so a stale file costs balance, never
  correctness.
- **By file, never by test.** Module fixtures and the `xdist_group` that pins a module's
  testcontainer to one worker assume the whole module runs in one process tree.
- **Proven complete.** Each shard writes the node ids it ran (`--shard-manifest`); the
  `coverage` job compares their union with a fresh `--collect-only` and fails if any test ran in
  no shard or in two. It also combines the shards' coverage and enforces the 90% floor there.
- **Readable when red.** The `coverage` job writes a job summary — failures, test seconds per
  shard, slowest files — and uploads a refreshed `test-durations` artifact. When the shards
  drift out of balance, `gh run download <run-id> -R MSKazemi/novafabric -n test-durations`
  then `make test-durations`, and commit `tests/.test-durations.json`.

The test session also pins its own colour environment (`tests/conftest.py`): Typer forces a
colour terminal when `GITHUB_ACTIONS`, `FORCE_COLOR` or `PY_COLORS` is set, which used to make
help-text assertions fail in CI only (issue #21). Tests that need colour set it themselves.

Releases are gated on this: every tag-triggered workflow waits for CI on the tagged commit and
publishes nothing unless it passed (`docs/release-process.md`, step 7).

### Tests

```bash
uv run pytest --cov=novafabric --cov-report=term-missing
```

Run a focused subset with `-k`:

```bash
uv run pytest -k test_validate -v
```

### Ruff (lint)

```bash
uv run ruff check src tests
uv run ruff check --fix src tests   # auto-fix safe issues
```

### mypy (types)

```bash
uv run mypy src
```

mypy is configured with `strict = true` in `pyproject.toml`. All type errors must
be resolved before merging.

---

## The PyPI README (build-time link rewrite)

`README.md` is written for GitHub, where `docs/foo.md` and `docs/assets/demo.svg` resolve
against the repository. pypi.org renders the same text with no repository root, so those
links and the demo image would break. `pyproject.toml` therefore declares
`dynamic = ["readme"]` and a hatchling metadata hook, `scripts/pypi_readme_hook.py`,
builds the long description: it rewrites every relative Markdown link and image,
reference definition, and HTML `src`/`href` to an absolute URL pinned to the release tag
`v{version}` (works today).

- Pages: `https://github.com/MSKazemi/novafabric/blob/v{version}/<path>` (a trailing `/`
  gives `tree/`).
- Images and media (`.png .jpg .gif .svg .webp .mp4` ...):
  `https://raw.githubusercontent.com/MSKazemi/novafabric/v{version}/<path>`.
- Unchanged: absolute URLs, `mailto:`, `//host/...`, same-page `#anchor` links, and
  anything inside fenced code blocks. Fragments on rewritten links are kept.

`README.md` itself is never modified, so GitHub renders it as before. The rewritten text
lands in the wheel's `METADATA` and the sdist's `PKG-INFO` (the sdist also carries
`scripts/pypi_readme_hook.py` and the original README, so a wheel built from the sdist gets the same
text). The links point at a tag, so they resolve once `v{version}` is pushed; a local
`uv build` before tagging produces links that 404 until then. The hook is stdlib-only and
offline (no new dependency; `hatch-fancy-pypi-readme` is MIT but cannot classify
page vs image or skip code fences). Guard:
`tests/packaging_metadata/test_pypi_readme_links_are_absolute.py` runs the rewrite on the
real README and fails if any relative link remains. To inspect by hand:
`uv build --out-dir "$(mktemp -d)"` and read `METADATA` / `PKG-INFO` (never build into the
repo's `dist/`).

## Adding a new asset type

The Asset Registry supports seven asset types (model, agent, prompt, tool, dataset,
evaluation, deployment). To add an eighth:

1. Add a new value to `AssetType` in `src/novafabric/spec/models.py`.
2. Add `<Type>SpecFields` and `<Type>Spec` Pydantic models following the existing
   pattern (see `ModelSpec`, `DatasetSpec`, etc.).
3. Add the new spec class to the `AssetSpec` union discriminator in `models.py`.
4. Add a test fixture in `tests/fixtures/valid_<type>.yaml` (the existing ones are
   `valid_agent.yaml`, `valid_model.yaml`, `valid_prompt.yaml`).
5. Add a test in `tests/test_spec_validator.py` asserting the fixture validates.

## Adding a new CLI command

1. Create `src/novafabric/cli/<command>.py` with a `<command>_cmd` function.
2. Register it in `src/novafabric/cli/main.py` with `app.command(...)`.
3. Add at least two CLI tests in `tests/test_cli.py`: a success path and an error path.
   **Routine operational failures must not surface as tracebacks.** A full disk, a missing
   optional extra, an unwritable directory or a bad permission is an *expected* condition on
   the machines NovaFabric runs on. Raise a **named** exception from the library layer with a
   message that says what was attempted, what failed, and the next move; catch it in the CLI
   and print one line. See `CapsuleDirectoryError` (`capture/orchestrator.py`, defect B11) and
   `_ALEMBIC_MISSING_HINT` (`metadata_store/cli.py`, defect B4) for the two shapes. Never let
   a bare `OSError`/`FileNotFoundError` reach the user.

   **Escape any literal `[` in a `help=` string as `r"\["`.** Typer renders help through Rich,
   which parses `[...]` as markup, so a plain `s3://bucket[/prefix]` raises `MarkupError` and the
   command answers `--help` with a traceback (defect B5). See `cli/verify.py` and `cli/_extras.py`
   for the convention. `tests/cli/test_every_command_help_renders.py` renders every command's
   help and will fail if you forget.
4. Regenerate the dashboard command registry so the CommandsTab stays a complete
   mirror of the CLI:
   ```bash
   uv run python ui/dashboard/scripts/gen-command-registry.py
   ```
   `tests/serve/test_command_registry_coverage.py` re-runs this introspection in
   CI and fails if the checked-in `generatedCommands.ts` drifts from the live
   Typer app — a forgotten regeneration is a test failure, not a silent gap.

   **Enumerating the CLI: use `novafabric.cli.introspect`.** Never walk the Typer
   app by hand, and never test for a command group with
   `isinstance(cmd, click.Group)`. Typer 0.27 vendors its own Click, so that check
   is `False` for a group that works perfectly — five hand-rolled copies of that
   walk each concluded the CLI had one command, which was read as a broken CLI and
   froze the Typer pin for weeks (ADR-0250). The same rule applies to HTTP routes:
   use `novafabric.serve.introspect`, not `isinstance(r, APIRoute)` over
   `app.routes`, which FastAPI 0.141 made empty for every included router.
5. If the new command deserves a real dashboard panel (not just a copy-only
   command builder), add a row for it in
   `ui/dashboard/src/components/dashboard/commands/commandParity.json`
   (`"status": "real-panel"` + `tab`/`api`); otherwise it defaults to
   `"builder-only"` and needs no entry.

**JSON output — never through a Rich console.** A `--json` / `--output-format json`
path writes with `novafabric.cli._output.emit_json(text)` (or a plain `typer.echo` /
`print` of `json.dumps(...)`), and nothing else goes to stdout on that path — warnings
belong on stderr. `console.print_json` syntax-highlights whenever colour is forced
(`FORCE_COLOR`, GitHub Actions) and `console.print` wraps at the terminal width and
reads `[...]` as markup; either one hands a CI step or a `> out.json` redirect
unparseable output. `tests/cli/test_json_output_is_plain.py` fails on both.

**Fixed-value options — use `str` Enum types.** When an option accepts a finite set
of values, define a `class MyOption(str, Enum)` in the same module and use it as the
type annotation. This gives shell tab-completion (via `nova --install-completion`) and
exits 2 with a clear error on invalid input instead of a runtime failure. See
`src/novafabric/cli/capture.py` (`RunnerName`) or `src/novafabric/cli/replay.py`
(`ReplayMode`) for the canonical pattern.

After any CLI change, verify the help text matches the implementation:

```bash
uv run nova --help
uv run nova <command> --help
```

## Adding a new dashboard tab or input

The dashboard is a static Astro/React app under `ui/dashboard/` (named `web/` before
ADR-0299 stage C; a path rename only). After editing source files,
rebuild and copy the bundle with the single dedicated script — **not** a plain
`astro build` followed by a manual `cp -r`, which would overwrite sibling
static directories (e.g. `topology/`) that other build steps own:

```bash
cd ui/dashboard && npm run build:dashboard   # astro build + scripts/copy-dashboard.mjs
# or, from the repo root:
make bundle
```

`copy-dashboard.mjs` copies **only the dashboard** into
`src/novafabric/serve/static/` (ADR-0299): the `dashboard/` shell, the `_astro/`
files that shell reaches (a transitive closure, not the whole directory),
`favicon.svg`, and the product-local `index.html` (forwards `/` to `/dashboard/`,
keeping `?token=`) and `404.html` from `ui/dashboard/serve-shell/`. Marketing routes
(`/concepts`, `/install`, `/why`, `/spec`, `/showcase/*`, `/docs/*`) and
`robots.txt` are not packaged; they live on `https://novafabric.ai`, and the
dashboard links there as plain external links. Other targets' output
(`topology/`) is left untouched. `tests/packaging_metadata/test_serve_static_is_dashboard_only.py`
fails if a marketing page comes back or a shipped chunk references a missing asset.

### Tests (v0.97.0)

The dashboard has its own test tiers — run them before rebuilding the bundle:

```bash
cd ui/dashboard
npm run lint        # tsc --noEmit, strict
npm run test:unit   # vitest + jsdom (primitives, hooks, nav invariants)
npx playwright test tests/e2e --reporter=line
```

> **On a machine already running `nova serve` on the Playwright port**, the
> config's `reuseExistingServer` will silently test *that* server's (possibly
> stale) bundle instead of your working tree. Pass `PW_PORT=<free port>` to get
> a clean one. CI sets `reuseExistingServer: false` and is unaffected.

`tests/unit/nav.test.ts` mirrors the Python command-parity guard
(`tests/serve/test_command_parity_classification.py`) in JS, so a navigation
change fails locally in vitest before it fails in CI.

### Design-system primitives (v0.97.0)

Build UI from `ui/dashboard/src/components/ui/primitives/` rather than raw Tailwind
strings — Button, Input, Select, Textarea, Field, Card, Badge, StatusPill,
SegmentedControl, Modal, Drawer, Tooltip, Toolbar, and `Icon` (a semantic
wrapper over lucide-react, so the icon set is swappable in one file). Colors,
elevation, spacing, and motion come from `ui/dashboard/src/styles/tokens.css`; **10px
(`--text-2xs`) is the minimum type size** — smaller text fails contrast checks.

| Component | Purpose |
|---|---|
| `primitives/*` | The design system — prefer these over hand-rolled markup. |
| `DataTable.tsx` | Virtualized table (TanStack Virtual) with sort, sticky header, `onEndReached` infinite scroll, and a `footer` slot. Use it for any list that can grow. |
| `TruncationNotice.tsx` | The ADR-0199 honesty affordance — "Showing N of ~M — load more". **A bounded list must never truncate silently.** |
| `SuggestInput.tsx` | Text input with a live-filtered suggestion dropdown. Accepts `suggestions: string[]`; shows up to 8 on focus, filters as you type. Click-outside and Escape close the dropdown; Enter fires the optional `onEnter` callback and bubbles so parent `<form>` `onSubmit` still triggers. Use this instead of a bare `<input>` whenever the field accepts a database reference (run ID, asset `name@version`, registry name, etc.). |
| `CopyButton.tsx` | Copy-to-clipboard button with a transient "copied" state. |

**Data fetching**: `lib/useQuery.ts` (reads), `lib/useMutation.ts` (writes), and
`lib/usePaginatedQuery.ts` — one hook covering both server pagination models
(ADR-0199 keyset cursors and offset/limit) that also surfaces the
`total`/`approximate`/`truncated` honesty signals for `TruncationNotice`.

### Tab structure (v0.97.0)

Tabs are decomposed: each large tab is a thin shell over a per-tab directory
(`tabs/compliance/`, `tabs/runs/`, `tabs/registry/`, `tabs/infra/`, `tabs/seal/`,
`tabs/admin/`, `tabs/kg/`, `tabs/governance/`). Add a panel as its own file, not
as another section inside the shell. `dashboard/PanelScaffold.tsx` owns the
repeated load → pending → error → result chrome; use it instead of re-writing
that state machine. Compliance additionally has a manifest (`tabs/compliance/index.ts`)
and a `?sub=` hub — a new panel is one file plus one manifest entry.

**Navigation invariants** (`Sidebar.tsx`): the `export type Tab = …` union is
parsed *textually* by the Python parity guard — keep it a single-quoted
string-literal union, and never move it to another file. Regrouping `NAV_GROUPS`
is free; renaming a tab id requires updating `commandParity.json` in lockstep.
Each tab declares a stable `g`-sequence shortcut key (`g h`, `g r`, …) —
shortcuts are per-tab, not positional, so reordering the sidebar is safe.

**Adding a new text input that references database data:**

1. Import `SuggestInput` from `../../ui/SuggestInput` (adjust path).
2. Load the suggestion pool from the server (if not already in scope) — `api.listAssets()`, `api.listRuns()`, or `api.listHolds()`.
3. Map to `string[]`: e.g. `assets.map(a => \`${a.name}@${a.version}\`)`.
4. Replace the bare `<input type="text">` with `<SuggestInput value={...} onChange={...} suggestions={pool} className={...} />`.
5. Rebuild and copy the bundle (see above).

## Adding a new `nova serve` API endpoint

Every serve endpoint mirrors an equivalent `nova` CLI command — the CLI + JSON surface
is canonical, and the dashboard is a view over it. (It is **not** read-only: Layer B
mutations ship, confirm-gated and audit-logged.) All serve endpoints follow the same
four-part pattern:

1. **Resolve the capsule** using `_resolve_capsule(run_id, capsule_dir)` — raises HTTP 404 if the directory doesn't exist.
2. **Require auth** with `dependencies=[Depends(verify_token)]` on the route decorator.
3. **Classify it** in `ROUTE_SCOPES` (`src/novafabric/serve/authz.py`). ⚠ **This step is not optional.** An unclassified route is **denied to everyone** — including the server token — because ADR-0228 D3 fails closed rather than defaulting to `read`. `tests/serve/test_authz_route_table.py` fails the moment you add a route without a line, so you will hit this in CI rather than in production.
4. **Mirror a CLI command** — every endpoint should have an obvious CLI equivalent noted in its docstring.

```python
@app.get("/api/example/{run_id}", dependencies=[Depends(verify_token)])
async def example_endpoint(run_id: str) -> dict[str, Any]:
    """Mirror of `nova example <run_id>`."""
    cdir = _resolve_capsule(run_id, capsule_dir)
    # ... do work using cdir ...
    return {"run_id": run_id, "result": ...}
```

```python
# src/novafabric/serve/authz.py — one line, in path order
("GET", "/api/example/{run_id}"): Scope.read,
```

**Pick the scope by what the handler does, not by its HTTP verb.** A `POST` that only
computes and returns is `read` — `POST /api/query` and the whole
`/api/compliance/export/*` family are classified that way, deliberately, so a read-only
auditor is not locked out of the exports that are the point of the auditor persona. Use
`operate` for a bounded reversible mutation, `admin` for anything irreversible,
privilege-affecting, key-handling, or that spawns user code, and `audit` only when the
response reveals *who did what*.

Add tests in `tests/test_serve_app.py` using the `client` fixture — three tests minimum: auth required (no token → 401), unknown run → 404, happy path → expected shape.

The matching dashboard panel goes in that tab's directory — `ui/dashboard/src/components/dashboard/tabs/<tab>/<Panel>.tsx` — and is rendered by the tab shell (see *Tab structure* above). Add a `Dashboard equivalent:` note in `docs/cli-reference.md` under the corresponding CLI command, and if the command now has a real panel, upgrade its `commandParity.json` entry from `builder-only` to `real-panel` with its `tab` and `api` (the guard checks the `api` string appears in **both** `ui/dashboard/src/lib/api.ts` and `src/novafabric/serve/`).

## Extending the query surface (filter bar, widgets, anything user-facing)

`novafabric.query`'s closed allow-list is a **security** property, not an ergonomic one: ADR-0235
validates widgets that arrive from untrusted sources against it. So any new surface that lets a
user express a query — a filter bar, a saved view, a widget field — must be **unable** to express
more than `nova query` can.

⚠ **Enforce that by delegation, never by duplicating the checks.** `query/filterbar.py` decides
*syntax* only (which field, which operator, is it negated) and hands DSL predicate text to
`parse_predicate`; the DSL answers every question of semantics. The first version of that module
duplicated the validation and drifted immediately — it accepted `log_level:value`, which the DSL
refuses because log levels are a closed set. Delegation removes the whole class, so a future
closed-set dimension inherits the guard for free.

⚠ **The ceiling means matching the DSL, not undercutting it.** An early version refused
`status:>error` on the grounds that it looks odd — but `nova query --where 'status > error'` is
accepted, so refusing it made the convenience unable to express something the CLI supports. Assert
both directions.

Two things the DSL genuinely cannot express today, worth knowing before designing a surface that
implies otherwise:

- **no glob / wildcard / `LIKE`** — the operators are exactly `= != < <= > >=` plus `IN`;
- **no metric predicates** — `--where` filters the eight `DIMENSIONS` only. A metric is something
  the DSL *aggregates*. `nova query --where 'cost > 0.5'` raises.

## Changing what the query indexer extracts

`src/novafabric/query/indexer.py` defines `CallRow`/`ScoreRow`, and
`src/novafabric/query/cache.py` derives its stored columns **from those dataclasses**
(`_CALL_FIELDS`). So adding a field is automatic for serialization — and that is exactly why the
version matters:

⚠ **If the indexer learns to read something new, bump `INDEXER_SCHEMA_VERSION` in the same
change.** A row cached under the old version has fewer values and rehydrates with the new fields
silently defaulted to `None`. When ADR-0233 added `parent_run_id`, an un-bumped cache would have
made **every capsule look like a root**, and a `--scope root|tree` query would have answered
confidently and wrongly from a warm cache — the wrong-but-fast outcome ADR-0225 D3 forbids.

⚠ **The converse matters just as much: do not bump for a feature that extracts nothing new.**
ADR-0236's `ratio()` is arithmetic over aggregates the indexer already produced; bumping for it
would discard every user's query cache for no reason. The constant's own docstring states the
test — *"whenever the **indexer** changes what it extracts"* — and that, not an ADR's
instruction, is what decides it.

Any new file the indexer opens must also be added to `INDEXED_FILENAMES`, which is what the
cache's change signature is derived from; a file read but not listed there is a stale-answer
defect, and `tests/query/test_indexer_signature_coupling.py` guards it.

## Adding an aggregate

Any surface that sums, counts, averages, or percentiles must return an ADR-0234 verdict from
`src/novafabric/serve/aggregates.py` rather than a bare number:

```python
from novafabric.serve.aggregates import AggregateCondition, computable, refuse

if store_is_unreachable:
    return refuse(
        AggregateCondition.SOURCE_UNAVAILABLE,
        reason="the cost store did not answer, so no figure can be established",
        remedy="check NOVA_CLICKHOUSE_URL; the capsule cost facet works offline",
    )
return computable(total, unpriced_calls=unpriced)
```

⚠ **A `remedy` is required and is checked at construction** — `refuse(..., remedy="")` raises.
*"Unavailable"* is not a remedy. ADR-0234 names a bad refusal message as the entire risk of this
design: users will meet refusals, and a refusal with nothing actionable in it is worse than the
approximation it replaced.

⚠ **A refusal carries no value.** Not zero, not null. The ADR rejects
render-the-approximation-with-a-badge precisely because a number on screen is read as a number
regardless of the badge beside it.

⚠ **"Absent is not zero" — and the converse.** A missing measurement (an unpriced call, a usage
component the provider never reported) must not be summed as `0`; the Run Capsule schema states
this as a format invariant. But a **count of nothing is legitimately `0`**, and the `or 0`
coercions over SQL counts are correct. Do not turn this rule into "zero is suspicious" — a rule
that refuses to report good news gets switched off.

## Adding a store to the `nova serve` read path

Any new backing store must declare its tenancy in `STORE_TENANCY`
(`src/novafabric/serve/tenancy.py`) as one of three classes:

| Class | Means |
|---|---|
| `aware` | it can answer *"show me only tenant X's rows"* — it carries a real discriminator |
| `agnostic` | it does not filter by tenant because the **deployment** supplies the isolation |
| `unsafe` | it does not filter by tenant and nothing else does either |

`agnostic` and `unsafe` both mean "does not filter by tenant" and have **opposite** safety
consequences. Collapsing them is how the second gets mistaken for the first, which is why there
are three values and not a boolean.

⚠ **`aware` is a claim about the schema, and `tests/serve/test_tenancy_registry.py` checks it
against the schema** — the module you name is read, and an `aware` store must really contain its
discriminator while an `unsafe` one must contain none. Declaring `aware` without a discriminator
fails; so does an `unsafe` store that has quietly grown a tenant column.

⚠ **Mentioning "tenant" is not being tenant-aware.** `object_capsule_store` mentions it fourteen
times and thirteen are per-tenant encryption keys (ADR-0243) — key material, which scopes no
query. Give the `evidence` field the specific thing that makes the claim true.

An `unsafe` store must carry a `reason` an operator can act on: it is what `nova serve` prints
when it refuses to start in multi-tenant mode, and a refusal with no *why* is noise.

### Touching the Postgres tenant context (FR-07 / FR-13)

The Postgres metadata store isolates tenants with row-level security keyed on the
`app.current_tenant_id` setting, written **only** as `SET LOCAL` inside
`begin_tenant_context()`'s transaction. A session-scoped write survives the transaction and
leaks one tenant's context to the next user of a pooled connection. Two guards hold the line
(**works today**):

- **Static, default-deny** (`tests/metadata_store/test_grep_constraints.py`). Every executable
  occurrence of a tenant-GUC or RLS primitive — `app.current_tenant_id`, `set_config`,
  `current_setting`, `FORCE`/`ENABLE ROW LEVEL SECURITY`, a libpq `options=-c …`, `PGOPTIONS` —
  is a finding unless it is one of the reviewed call sites in `KNOWN_GOOD_SITES`. Matching is
  case-insensitive and whitespace-tolerant, and sees through concatenation, f-strings and
  `.format`; comments and docstrings are ignored. Any such primitive outside `metadata_store/`
  fails FR-13. **Adding a call site — even a correct one — fails until you add it to
  `KNOWN_GOOD_SITES` with a one-line reason;** that is the review step, not a false positive.
- **Behavioural** (`tests/metadata_store/test_tenant_guc_behaviour.py`). It drives the real store
  against a recording connection and checks every statement it *executes*, parameters bound and
  `psycopg.sql` composition rendered: the only accepted tenant-GUC writes are `SET LOCAL … = '<uuid>'`
  and `set_config(…, true)`, inside the transaction, with no session options on the connection.
  A container test pins the checker against a real Postgres.

A static scan cannot be complete — a name assembled at run time from pieces it never sees whole
can evade it. That is what the behavioural guard is for: route every tenant-GUC write through
`begin_tenant_context()` so the guard exercises it.

## Adding a capsule facet

A **facet** is an additive block under `capsule["facets"]` recording one kind of
evidence about a run. It is the most-repeated extension pattern in this codebase —
28 modules as of 2026-09-03 — so it has a contract, and
`tests/docs/test_capsule_facet_contract.py` enforces it across every module rather
than naming them.

### The contract

Every facet module defines:

| symbol | why |
|---|---|
| `FACET_NAME` | the key under `capsule["facets"]`. Must be unique — two modules claiming one key means one silently overwrites the other, which the guard rejects. |
| `SCHEMA_VERSION` | a facet without one cannot be read back safely once its shape changes. |
| `attach_facet(capsule, facet)` | writes it in, additively. |

and registers `FACET_NAME` in the capsule schema. ⚠ **This step is not optional and is easy to miss.** `properties.facets` is `additionalProperties: false` (ADR-0196 D2), so a facet name the registry does not know makes every capsule carrying it fail
`nova validate` with *"Additional properties are not allowed"*. Add one entry —

```json
"my_evidence": { "type": "object", "description": "See ADR-0XXX." }
```

— to **all three** live copies:

- `src/novafabric/schemas/run-capsule.schema.json` — what an installed CLI validates against
- `schemas/run-capsule.schema.json` — the OAS v1.0 target (not yet in force, ADR-0034 §1)
- `ui/dashboard/src/data/schemas/run-capsule.schema.json` — the dashboard's copy, kept identical to
  the packaged one by `tests/packaging_metadata/test_site_schemas_match_packaged.py`

Use `"type": "array"` when your facet is a *list* of records rather than one object.
`tests/docs/test_capsule_facet_contract.py` fails if any declared `FACET_NAME` is absent
from any of the three. **On 2026-09-03 that check did not exist and 13 of 28 facet names
were unregistered** — every one of those modules had a green `attach_facet` test against a
plain `dict`, and the schema had its own green validity test, and nothing spanned the two.
Your `attach_facet` test proves the shape you write; only a test that runs the real
validator proves a capsule carrying it still validates. Write both.

```python
FACET_NAME = "my_evidence"
SCHEMA_VERSION = "0.1.0"


def attach_facet(capsule: dict[str, Any], facet: MyFacet | None) -> dict[str, Any]:
    if facet is None:
        return capsule            # fail-open: no material, no facet
    out = dict(capsule)           # never mutate the caller's dict
    facets = dict(out.get("facets") or {})
    facets[FACET_NAME] = facet.model_dump(exclude_none=True)
    out["facets"] = facets
    return out
```

### The four invariants every existing facet honours

1. **Additive.** A capsule that carries no facet of your kind must come back
   **byte-identical** to one captured before your feature existed. Test that
   directly — it is the property that lets facets ship without a schema break.
2. **Fail-open.** Absent material means no facet. Never an exception, never a
   blocked workload. NovaFabric must not break the thing it is observing.
3. **Digests, not content.** Anything that can carry user data — prompts, message
   parts, retrieved documents, payment details — is bound by a `sha256:` digest and
   never stored (ADR-0009). A test that greps the serialised facet for the
   plaintext is cheap and catches the regression that matters.
4. **`exclude_none=True` when dumping.** In a layer whose point is that "not
   recorded" differs from "recorded as nothing", that distinction has to survive
   serialisation.

### Reading a facet back is optional, and deliberately so

21 of the 28 modules also define `facet_from_capsule(capsule) -> Facet | None`.
The other 7 do not, and that is **not a backlog item**: nothing reads those facets
back today, so adding readers would mean seven functions with no callers. This
project has made that mistake once already (`bind_recorder` — built, documented,
tested, zero production callers), and the standing lesson is *wire it when a caller
needs it, not before*.

So add a reader when something needs to read. When you do:

```python
def facet_from_capsule(capsule: dict[str, Any]) -> MyFacet | None:
    facets = capsule.get("facets")
    if not isinstance(facets, dict):
        return None
    block = facets.get(FACET_NAME)
    if not isinstance(block, dict):
        return None
    try:
        return MyFacet.model_validate(block)
    except ValueError as exc:
        raise MyFacetError(f"capsule holds an invalid {FACET_NAME} facet: {exc}") from exc
```

Absent is `None` — the overwhelmingly common case, and not an error. Malformed
raises **your** error type, so a caller never has to catch a Pydantic internal.

---

## Adding a new report format

`generate_report(format_, db_path=None)` in `src/novafabric/report/generator.py`
dispatches on `format_` with a chain of early-return `if format_ == "<name>":`
checks (currently `json`, `html`, falling through to markdown as the default).
Binary formats that need to render the HTML first (e.g. `pdf` via WeasyPrint,
see `generate_report_pdf`) live as a separate function rather than returning
from `generate_report` itself.

1. Add a new `if format_ == "<name>": return ...` branch (or a sibling
   `generate_report_<name>()` function for a binary format) in `generator.py`.
2. Add the value to the `ReportFormat` enum in `src/novafabric/cli/report.py`
   and dispatch on it in `report_cmd` (mirror the `pdf` branch if the format is
   binary and needs `--output`).
3. Add a test in `tests/test_report.py` asserting the output is valid for the
   format, and a CLI test for the new `--format` value.

## Extending failure attribution (diagnose/, ADR-0084)

`src/novafabric/diagnose/` is a pure, read-only analysis layer over the captured
trace plus the lineage store. `attribute_failure(capsule_dir, lineage_store=None)`
returns a `RunAttribution` (ranked `list[StepAttribution]`, chosen responsible step,
and an `AgentErrorTaxonomy` label).

To extend it:

1. **Add a taxonomy cue.** Append keyword cues to the appropriate entry in
   `_TAXONOMY_CUES` in `attribution.py`. Cues are matched case-insensitively against
   the step's error text + name; the first matching category wins, so order matters
   (SYSTEM is checked before ACTION to catch infrastructure faults).
2. **Add a scoring signal.** In the coarse pass of `attribute_failure`, add a bounded,
   additive term to `score`. Keep terms small and bounded (no unbounded weights) and
   keep the function deterministic for a given capsule.
3. **New taxonomy category.** Extend the `AgentErrorTaxonomy` enum — it is a public,
   stable surface, so adding a member is additive but removing/renaming one is a
   breaking change (update ADR-0084 and the CLI reference if you do).
4. **Test first.** Add a fixture in `tests/test_attribution.py` (a multi-step failed
   run where the responsible step is known) asserting the rank and label.

Do **not** write attribution results back into the lineage store from this module —
it is read-only by design (ADR-0084). Scores are relative ranking weights, never
presented as calibrated probabilities.

**Related, newer modules in `diagnose/` (ADR-0101, experimental):**

- `verify.py::verify_hypothesis` / `search_root_cause` back the CLI's
  `nova diagnose --intervene` and `--search-root-cause` flags — they replay the
  capsule counterfactually (ADR-0086, mocked/zero-token) and record an
  evidence-based `CONFIRMED`/`REFUTED`/`INCONCLUSIVE` verdict rather than a
  guess.
- `causal_graph.py::causal_root_candidates` (NF-019/022) back-traces the span
  graph to root failure nodes (a failing node with no failing ancestor). It has
  **no CLI verb of its own** — it's consumed internally by
  `search_root_cause`. Every candidate carries `verification="unverified"`
  until an intervention replay confirms it.
- `claim_audit.py::audit_claims` (NF-021) marks a model-span claim `ungrounded`
  when no tool-span evidence precedes it on the answer path — a structural
  hallucination-risk signal, no NLP. **This is Python-API only today** — it is
  not wired into `nova diagnose`'s output or any CLI command. If you add a CLI
  surface for it, follow the "Adding a new CLI command" pattern above and
  update `docs/user-guide.md`'s v0.75–v0.94 cohort table to move it from
  "Python API only" to "CLI".

## Changing what mocked replay serves (ADR-0300, ADR-0304)

Mocked replay serves a recorded response only on an SDK surface it patches. Three places
must agree, and a test holds them together:

1. **Capture** (`capture/hooks/_openai.py`, `_anthropic.py`) must record the call *with its
   response* in the canonical model-call shape, and mark the record with
   `extensions["io.novafabric.api_surface"]`. A streamed call goes through
   `capture/hooks/_sdk_streams.py` (a transparent proxy that folds the chunks and writes
   one record when the stream ends). Run the SDK call inside
   `record_roles.sdk_call_scope(call_id)` and write the SDK record with that `call_id`
   (stamped by `stamp_logical_record`), so the wire hook's records underneath become
   `transport` records of it (ADR-0305). A new wire hook calls `stamp_wire_record`.
   Any code that counts or iterates model calls goes through
   `record_roles.logical_model_calls` — never `len()` of the file — and a serving rule
   must reject `is_transport_record(record)` before anything else.
2. **Replay** (`replay/_contract.py:model_queue_key` + `MODEL_SURFACES`, and
   `replay/_dispatcher.py:SERVED_MODEL_SURFACES` / `UNSUPPORTED_MODEL_SURFACES`) decides the
   queue a record joins and which SDK methods are served or refused. Never let a method go
   live silently: if it is not served, add it to `UNSUPPORTED_MODEL_SURFACES`.
3. **The support matrix** — edit the rows in `replay/_support_matrix.py`, then run
   `uv run python scripts/gen_replay_support_matrix.py`. Never edit the table in
   `docs/architecture/replay-modes.md` by hand: `tests/replay/test_support_matrix_is_generated.py`
   fails if the page drifts, if a row's served/refused claim disagrees with the dispatcher's
   patch tables, or if a cited test does not exist.

Test a new surface end to end the way `tests/replay/test_model_surface_coverage_e2e.py`
does: `nova capture` a real agent process (the real SDK over `httpx.MockTransport`), then
`nova replay` it with the network refused, and assert the agent observed the same thing both
times and that every recording was consumed.

## Adding a compliance audit profile

Compliance profiles live in `src/novafabric/compliance/audit/profiles/` as YAML files,
parsed by `compliance/audit/loader.py` into a `ControlProfile`
(`compliance/audit/models.py`). Each file names a framework and lists its controls; each
control lists the evidence a capsule must carry:

```yaml
# src/novafabric/compliance/audit/profiles/my-standard.yaml
id: my-standard-v1
name: My Standard v1.0
framework: My Standard
version: "1.0"
controls:
  - id: MS-01
    title: Model calls are recorded
    description: Every model interaction is captured in the run capsule.
    weight: 1.0                 # optional, default 1.0
    evidence_requirements:
      - evidence_type: model_calls
        required: true          # optional, default true
        description: model-calls.jsonl present in the capsule
  - id: MS-02
    title: Capsule is sealed
    description: The capsule carries a verifiable signature.
    evidence_requirements:
      - evidence_type: seal_signature
        description: A NovaSeal signature over the manifest
```

`id`, `name`, `framework`, `version` and `controls` are required at the top level; `id`,
`title` and `description` on each control; `evidence_type` and `description` on each
requirement. A missing field or wrong type raises `ProfileValidationError` naming the file
and field. `evidence_type` must be one of the tokens the engine has a checker for
(`_EVIDENCE_CHECKERS` in `compliance/audit/engine.py`): `model_calls`, `tool_permissions`,
`pii_scan`, `policy_events`, `seal_signature`, `lineage`. An unknown token is logged as a
warning and treated as **absent**, so the control can never be covered.

To ship the profile as a built-in ID, add it to `_PROFILE_FILENAMES` in
`compliance/audit/loader.py`. That map is what `--profile <id>`, `list_profiles()` and
`nova serve` resolve, and the `--profile` help text in `src/novafabric/cli/audit.py` is built
from it (`BUILT_IN_PROFILES`), so there is no second list to edit. Without the map entry the file is still usable by path:
`nova audit map --profile path/to/my-standard.yaml`. Run
`nova audit map --profile my-standard` (or the path) to confirm it loads.

> **Compliance-honesty reminder.** Audit profiles and exporters produce *evidence that
> supports* a compliance workflow. NovaFabric attests only that a capsule is unmodified
> since signing; it does not certify or guarantee compliance with any regulation
> (EU AI Act, NIST AI RMF, ISO/IEC 42001, GDPR, HIPAA, FDA 21 CFR Part 11, SOC 2, etc.).

## Compliance exporters — adding a new format

Compliance exporters live in `src/novafabric/compliance/export/`. There is no shared base
class or method contract: most exporters are a class with a *build* method that reads a
capsule into a Pydantic model and an `export_json` that writes it, but the build method's
name differs per exporter (`GDPRRoPAExporter.build_ropa_entry()` / `build_ropa_document()`,
`AIBOMExporter.build_aibom()`, `NISTAIRMFReporter.build_report()`,
`AnnexIVExporter.build_annex_iv_document()`, `NIS2Exporter.build_nis2_report()`), and the
RO-Crate exporter is a plain function, `export_ro_crate(capsule_dir, output_path)`. A new
capsule-reading exporter can follow the common shape:

```python
class MyExporter:
    def build_report(self, capsule_dir: Path) -> MyReport:
        """Build the report model from capsule files."""
        ...

    def export_json(self, report: MyReport, output_path: Path) -> Path:
        """Serialise to JSON and return the written path."""
        ...
```

**A document-in, document-out exporter goes in the generic registry instead** (works today,
ADR-0200 §2). If your builder takes a self-contained JSON document (refs and digests, never
raw capsule bytes) and returns a JSON document, add one `ExportKindSpec` entry to
`EXPORT_KINDS` in `src/novafabric/serve/routers/compliance_exports.py` — kind, label,
`cli_equivalent`, field specs and the *same* pure builder function the CLI command calls.
`GET /api/compliance/export/kinds` then lists it, `POST /api/compliance/export/{kind}`
runs it (audit-logged with its `cli_equivalent`), and the dashboard's single dynamic export
panel renders its form, so steps 4–6 below are not needed. 13 kinds are registered today
(`foia`, `whistleblower`, `part11`, `model-risk`, …).

**Wiring up a new exporter:**

1. Create `src/novafabric/compliance/export/my_format.py` with the exporter class.
2. Add a CLI command in `src/novafabric/cli/export_evidence.py` (see `export_ropa_cmd` as a template).
3. Register the CLI command in `src/novafabric/cli/main.py`.
4. Add a `nova serve` endpoint in `src/novafabric/serve/app.py` following the `compliance_export_ropa_endpoint` pattern — import the exporter inside the function to avoid unconditional heavy imports.
5. Add an `api.ts` method in `ui/dashboard/src/lib/api.ts` (see `exportRopa` as a template).
6. Add a `<MyFormatExportPanel>` component as its **own file** under `ui/dashboard/src/components/dashboard/tabs/compliance/` (built on `PanelScaffold`), then register it in the manifest at `tabs/compliance/index.ts` with its `group` — the hub renders whatever the manifest declares. (Before v0.97.0 these panels lived inline in `ComplianceTab.tsx`; that file is now just the sub-navigation shell.)
7. Write integration tests in `tests/test_serve_compliance.py` — one happy-path test and one 422 on missing `run_id`.

**Existing exporters for reference:**

| Exporter | File | Cap | Dashboard panel |
|---|---|---|---|
| `GDPRRoPAExporter` | `compliance/export/gdpr_ropa.py` | cap-007 | `RoPAExportPanel` |
| `AIBOMExporter` | `compliance/export/aibom.py` | cap-008 | `AIBOMExportPanel` — CycloneDX 1.7 |
| `NISTAIRMFReporter` | `compliance/export/nist_rmf.py` | cap-009 | `NISTRMFExportPanel` |
| `AnnexIVExporter` | `compliance/export/annex_iv.py` | cap-002 | `AnnexIVPanel` |
| `NIS2Exporter` | `compliance/export/nis2.py` | cap-005 | `NIS2Panel` |
| `export_ro_crate()` (function) | `compliance/export/ro_crate.py` | — | `RoCrateExportPanel` |

---

## Working with NovaSeal (trust/novaseal/)

> **Maturity: experimental / engineering toward planned architecture.** NovaSeal is the
> in-tree signing subsystem tracked by ADR-0041. It is *not* part of the shipped
> local-first product surface as a supported feature; interfaces here may change. The
> shipped trust primitive is the ed25519-signed **Evidence Bundle** built by
> `nova export-evidence`, verifiable offline with only `sha256sum` plus an ed25519
> verifier.

The NovaSeal signing code lives entirely in `src/novafabric/trust/novaseal/`.
Its tests are in `tests/seal/`. All tests in that directory run fast (no network
calls — TSA requests are mocked).

**Running NovaSeal tests only:**

```bash
uv run pytest tests/seal/ --benchmark-disable -v
```

**Running the seal latency gate** (enforced in CI, ~1 s locally):

```bash
uv run pytest tests/seal/test_benchmark.py -v
# or:
make benchmark
```

The gate (`test_seal_latency_gate`) runs 100 `pedantic` rounds of
`NovaSeal.seal()` and asserts that the median < 50 ms; a nearest-rank p99
≥ 200 ms raises a non-blocking `SealTailLatencyAlarm` (and a GitHub
`::warning::` annotation) instead of failing, because on a shared runner the
100-round p99 is the second-slowest sample and two scheduler stalls decide it.
Both thresholds are read from `tests/bench/slo_catalog.toml`.  It skips
automatically when `--benchmark-disable` is active so it does not slow down
normal `make test` runs.  Results are written to
`.benchmark-results/seal_latency.json` by `make benchmark` and saved as a
90-day artifact in the `seal-latency-gate` CI job.

**Adding a new signing profile** (e.g. Sigstore keyless — planned):

1. Add the new `profile:` value to `_SUPPORTED_PROFILES` in `config.py` (an unknown value
   is refused there), and its fields to the `SigningProfile` dataclass.
2. In `config.py._parse_profile`, add validation for the new profile's required fields.
3. In `config.py.build_signing_backend`, dispatch on `profile.profile` and return a
   `SigningBackend` for it. `create_envelope` itself needs no change: it signs through
   whatever backend it is handed, and capture finalization (`capture/finalize.py`) builds one
   with `build_signing_backend` for every non-`local` profile.
4. Add fixtures in `tests/seal/` for the new profile.
5. Write an ADR if this changes the public wire format (DSSE envelope schema).

**Using the Postgres Merkle log backend:**

Install the optional extra:

```bash
pip install novafabric[seal-postgres]
```

Point `NOVAFABRIC_SEAL_DB_PATH` at a Postgres DSN (or set `merkle_db` in `novaseal.yaml`):

```bash
export NOVAFABRIC_SEAL_DB_PATH=postgresql://user:pass@host:5432/nova
nova seal log verify          # samples the entry re-hash; the root pass is
                              # always O(N) by design (ADR-0268) — ~1.9 s
                              # at 1M entries, measured on a CI runner
nova seal log verify --full   # full O(N) re-hash audit
```

`NovaSeal.__init__` calls `open_merkle_log(db_path)` which dispatches by URI prefix:
- `Path` or non-DSN string → `MerkleLog` (SQLite, default)
- `postgresql://` or `postgres://` prefix → `PostgresMerkleLog` (psycopg3)

Tables are self-bootstrapped on first connection (`CREATE TABLE IF NOT EXISTS`).
No Alembic migration is needed. The one schema change so far is additive and applied by
the log itself on open (ADR-0313): a nullable `root_version` column on the tree-head
table (NULL = v1) and a `log_meta` / `nova_seal_log_meta` table holding the log's root
version. A log that already has leaves and no recorded version is recorded as v1.
Tests for the Postgres backend are in `tests/seal/test_postgres_merkle.py`, gated
behind `NOVA_INTEGRATION=1` + `NOVA_TEST_POSTGRES_DSN`.

**Seal bundle location convention:**

All sealing code writes to `<capsule_dir>/.seal/`:
- `manifest.dsse` — DSSE envelope bytes (UTF-8 JSON)
- `manifest.dsse.tsr` — RFC 3161 TSR bytes (DER binary; empty if TSA skipped)
- `log-entry.json` — Merkle log entry (JSON, human-readable)

---

## Working with the Promote package (promote/)

The maker-checker linked-envelope chain lives in `src/novafabric/promote/`.
Tests are in `tests/promote/`. Test key fixtures live in `tests/fixtures/promote/keys/`
(ECDSA P-256, test-only). They are **not committed** — the blanket `*.pem` gitignore rule keeps
them out — and are generated by `tests/fixtures/promote/generate_keys.py`. The test session
runs it on demand when they are absent (`tests/conftest.py`); to use the keys outside pytest,
for the smoke test below, run `uv run python tests/fixtures/promote/generate_keys.py` once.

**Running promote tests only:**

```bash
uv run pytest tests/promote/ -v --tb=short
```

**Module responsibilities:**

| Module | Responsibility |
|---|---|
| `predicates.py` | DSSE sign/verify with promote payload types; predicate builders; JSON Schema validation via `jsonschema`; JCS-based `proposal_digest` |
| `policy_store.py` | SQLite `promote_policy` table in the NovaSeal Merkle log DB; `get_by_version`, `get_active_at`, `get_latest`, `put` |
| `bundle_store.py` | Filesystem proposal/approval DSSE bundle CRUD; paths `{data_dir}/promote/{capsule_id}/proposal/{uuid}.json` |
| `verifier.py` | `verify_sod()` + `VerifyResult` dataclass; five checks with distinct exit codes 3–7 |
| `exceptions.py` | `PredicateValidationError`, `PolicyNotFoundError`, `BundleNotFoundError`, `SoDError` |

**DSSE payload types used:**

```python
PROPOSAL_PAYLOAD_TYPE = "application/vnd.novafabric.promote.proposal+json"
APPROVAL_PAYLOAD_TYPE = "application/vnd.novafabric.promote.approval+json"
POLICY_PAYLOAD_TYPE   = "application/vnd.novafabric.promote.policy+json"
```

These are distinct from the single-signer NovaSeal `PAYLOAD_TYPE` (`application/vnd.novafabric.capsule+json`). The `verify_promote_envelope()` function in `predicates.py` checks the payload type; do not use `verify_envelope()` from `trust/novaseal/envelope.py` for promote bundles.

**Adding a new predicate type.** The bypass predicate is the worked example — it went through
exactly these steps and ships today (`BYPASS_PAYLOAD_TYPE`, `build_bypass_predicate()`,
`nova seal bypass`). A candidate for the next one is a bypass *review* record: its schema,
`src/novafabric/schemas/promote_bypass_review_v1.json`, is packaged, but no code builds or
verifies it yet.

1. Add the JSON Schema to `src/novafabric/schemas/promote_<name>_v1.json`;
   `validate_predicate("promote_<name>_v1.json", predicate)` loads it by filename.
2. Add a `<NAME>_PAYLOAD_TYPE` constant to `predicates.py`.
3. Add `build_<name>_predicate()` to `predicates.py`.
4. Add the CLI command in `src/novafabric/cli/seal_propose.py` under `seal_app`.
5. Add tests in `tests/promote/`.

**Smoke test (local, no network).** The fixture certificates are self-signed and no promote
trust root is configured here, so `approve` and `verify` need `--insecure-self-signed`
(development only; production passes `--ca-bundle <pem>` or sets
`NOVAFABRIC_PROMOTE_CA_BUNDLE`). Without it `approve` exits 1 and `verify` exits 3 with
*no promote trust root is configured*.

```bash
KEYS=tests/fixtures/promote/keys
TMPDIR=$(mktemp -d)
uv run nova policy sign --key $KEYS/admin.pem --cert $KEYS/admin_cert.pem \
  --proposer-subjects "proposer" --approver-subjects "approver" --db "$TMPDIR/m.db"
UUID=$(uv run nova seal propose test-capsule-001 \
  --justification "Sprint 1 smoke test: model v1.0 validation complete." \
  --key $KEYS/proposer.pem --cert $KEYS/proposer_cert.pem \
  --db "$TMPDIR/m.db" --data-dir "$TMPDIR" | grep "Proposal created:" | awk '{print $3}')
echo y | uv run nova seal approve "$UUID" --capsule-id test-capsule-001 \
  --key $KEYS/approver.pem --cert $KEYS/approver_cert.pem \
  --db "$TMPDIR/m.db" --data-dir "$TMPDIR" --insecure-self-signed
uv run nova seal verify test-capsule-001 --db "$TMPDIR/m.db" --data-dir "$TMPDIR" \
  --insecure-self-signed
# Expected: SoD verification passed
```

---

## Working with EventEnvelope (envelope/)

The Event Envelope v1 wire format lives in `src/novafabric/envelope/`.
Its tests are in `tests/test_event_envelope.py`.

**Running Event Envelope tests only:**

```bash
uv run pytest tests/test_event_envelope.py -v
```

**Adding a new event_type:**

1. Add the new string value to `EventType` in `envelope/models.py`.
2. Add the same string to the `"enum"` array in `schemas/event-envelope-v1/envelope-v1.json`.
3. Recompute `schemas/event-envelope-v1/envelope-v1.sha256`:
   ```bash
   python3 -c "
   import hashlib, pathlib
   c = pathlib.Path('schemas/event-envelope-v1/envelope-v1.json').read_bytes()
   if c.endswith(b'\n'): c = c[:-1]
   pathlib.Path('schemas/event-envelope-v1/envelope-v1.sha256').write_text(hashlib.sha256(c).hexdigest())
   "
   ```
4. Update `envelope-v1.proto` with the same new string in a comment next to `event_type`.
5. Add tests in `tests/test_event_envelope.py` for the new event_type.

**Changing the envelope schema (adding an optional field):**

Adding an optional field is backwards-compatible (additive). Steps:
1. Add the field to `schemas/event-envelope-v1/envelope-v1.json` with `"oneOf": [{"type": ...}, {"type": "null"}]` (optional) or add it to `"required"` (new required field — only add new required fields before any consumers depend on the current schema).
2. Add the field to `envelope-v1.proto` with the next available tag number (alphabetical order).
3. Add a Pydantic field to `EventEnvelope` in `envelope/models.py` with a default of `None`.
4. Recompute the sha256 pin (see above).
5. Update this file and [`docs/architecture.md`](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md).

**Breaking changes require `envelope_version: "2"`** — removing fields, changing types, or making optional fields required. These are forbidden in the v1.x line. Write an ADR before proceeding.

**Schema hash pin enforcement:**

Downstream consumers embed the sha256 in their schema references. A mismatch between the expected and actual hash is a build-time error. Always recompute the pin after editing `envelope-v1.json`.

**CloudEvents interop (gap-009, ADR-0081):**

To route envelopes through a CloudEvents-aware broker (Kafka/NATS/HTTP) without
adding NovaFabric-specific parsing, use the structured-mode mapping in
`src/novafabric/envelope/cloudevents.py`:

```python
from novafabric.envelope import EventEnvelope, to_cloudevents, from_cloudevents

ce = to_cloudevents(envelope)          # plain JSON-serializable dict (CloudEvents v1.0)
envelope2 = from_cloudevents(ce)       # round-trips back to an equivalent EventEnvelope
```

`to_cloudevents()` always emits the four required CloudEvents context attributes
(`id`, `source`, `type`, `specversion="1.0"`). The mapping is **additive** — the
internal `EventEnvelope` model is untouched and the schema pin does not change.

Key contract (mirrors `collector/pkg/envelope/cloudevents_mapping.go` so
Python↔Go interop holds over the same topic):

| EventEnvelope field | CloudEvents attribute |
|---|---|
| `event_id` | `id` |
| `agent_id` | `source` (`nova://agent/{agent_id}`) |
| `event_type` | `type` |
| `started_at` | `time` |
| `trace_id` + `span_id` | `traceparent` extension (`00-{trace}-{span}-01`) |
| `global_run_id` / `run_id` | `novarunglobal` / `novarun` extensions |
| `parent_run_id` / `cluster_id` / `tenant_id` / `emitter_node_id` / `payload_hash` | `novaparentrun` / `novacluster` / `novatenant` / `novaemitter` / `novapayloadhash` (omitted when null) |
| `nova.batch.signature` / `nova.batch.signing_key_id` | `novabatchsig` / `novabatchkid` |
| `payload` | `data` |

Extension attribute names are lowercase `[a-z0-9]` per the CloudEvents naming
rule (no hyphens or dots). Unknown broker-injected extension attributes are
preserved across a `from_cloudevents()` → `to_cloudevents()` round-trip (stashed
under the model's `cloudevents_extensions` extra block). Tests:
`tests/test_envelope_cloudevents.py`.

> **Out of scope (Wave 2):** SCITT COSE Receipts — the other half of gap-009 —
> are not yet implemented.

---

## Standard outer envelopes (envelopes/) — experimental

The `src/novafabric/envelopes/` package (NF-029/030/031, ADR-0096, **experimental**) wraps
NovaFabric's inner artifacts in stock, third-party-verifiable envelopes — **wrap, don't replace**:
the inner bytes become the envelope payload verbatim and are never rewritten. Tests live in
`tests/envelopes/`.

| Module | Emitter | Envelope |
|---|---|---|
| `envelopes/dsse.py` | `wrap_bundle(bundle_bytes, signer)` | DSSE, `payloadType application/vnd.novafabric.bundle+json` |
| `envelopes/intoto.py` | `capsule_statement(capsule_dir, …)` | in-toto Statement v1, `predicateType novafabric.dev/capsule/v1` (per-file sha256 subjects) |
| `envelopes/slsa.py` | `promotion_provenance(…)` | in-toto Statement, `predicateType https://slsa.dev/provenance/v1` |

**Single DSSE writer (do not fork).** All three emitters sign through the one PAE + signer
implementation in `evidence/intoto.py` — `dsse_sign_payload(payload, payload_type, signer)` (and its
statement-level wrapper `dsse_sign(statement, signer)`). A `signer` is any object exposing
`sign(bytes) -> bytes` and `keyid: str` (e.g. `evidence/signing.py::LocalSigner`, or the keyring-key
adapter in `cli/promote.py`). **Never add a second DSSE code path** (requirement 2 of ADR-0096).

**Digest fidelity.** `capsule_statement(..., expected_digests=...)` raises `SubjectDigestMismatch` if a
recomputed per-file sha256 disagrees with an expected one — the emitter refuses to produce a
verifying-but-wrong attestation.

**Schema conformance.** `envelopes/_schemas/` vendors the in-toto Statement v1 and SLSA Provenance v1
required-field contracts; `envelopes/schema.py::validate_intoto_statement()` /
`validate_slsa_provenance()` assert emitter output against them (raising `EnvelopeSchemaError`), so a
renamed/missing field fails fast instead of producing an envelope a stock verifier would reject. These
JSON files are shipped as wheel package data (`pyproject.toml [tool.hatch.build.targets.wheel].include`) —
add any new vendored schema to that list.

**CLI surfaces (all opt-in; output byte-for-byte unchanged when the flag is absent):**
- `nova export-evidence --dsse` — DSSE-wraps the final bundle `manifest.json` → `<bundle>.dsse.json`.
- `nova promote direct --slsa-provenance` — emits a DSSE-signed SLSA provenance → `<name>-<version>.slsa.json`.
- `nova verify-envelope <env.json> --key <pem>` — verifies any of the above with an Ed25519 key.

> **Three distinct DSSE verify paths now coexist — do not cross them:**
> 1. **`trust/novaseal/envelope.py::verify_envelope()`** — a *capsule's* NovaSeal seal
>    (`application/vnd.novafabric.capsule+json`; also checks RFC 3161 + Merkle). Used by `nova verify`.
> 2. **`promote/predicates.py::verify_promote_envelope()`** — maker-checker promote bundles
>    (`…promote.proposal/approval/policy+json`).
> 3. **`cli/verify_envelope.py` (`nova verify-envelope`)** — the *outer* envelopes above
>    (`…bundle+json`, in-toto, SLSA), verifying the signature only via `evidence/intoto.py::dsse_verify`.
>
> All three sign over the DSSE v1 *PAE* (`"DSSEv1" SP LEN(type) SP type SP LEN(body) SP body`),
> so stock DSSE tooling verifies them. Paths (1) and (2) also *accept* — on verification only —
> the non-standard little-endian PAE that capsule seals and promote envelopes were signed with
> through v0.102.x, and `nova verify` flags such a capsule as a **legacy envelope**. Never sign
> over `_pae_legacy`; never add a third PAE. They carry different payload types and check
> different things.
> Pick by artifact: capsule seal → (1); promote bundle → (2); standard outer envelope → (3).

**Adding a new outer envelope type:** add an emitter module under `envelopes/` that builds the
statement/payload and signs via `dsse_sign`/`dsse_sign_payload` (never a new signer), add tests under
`tests/envelopes/`, and — if it introduces a user-facing surface — wire an opt-in CLI flag and
label it **experimental** in the docs that describe it. Write an ADR only if it changes a public payload type.

---

## Working with the SPKG (`kg/spkg/`) — experimental

The Security & Provenance Knowledge Graph ([ADR-0111](https://novafabric.ai/docs/decisions/)) turns capsule lineage
into a security-reasoning graph. It has two layers and a detector, all under `src/novafabric/kg/spkg/`:

| Module | Role | Heavy deps? |
|---|---|---|
| `ontology.py` | Namespace IRIs (PROV-O, ATT&CK, D3FEND) + SHACL shapes (incl. `nf:FindingShape`) | rdflib **lazy** — imports without the extra |
| `provo_mapping.py` | `read_lineage_edges()`, `lineage_edge_to_provo()`, `capsule_lineage_to_provo()`, `finding_to_rdf()`, `validate_provo()` | rdflib/pyshacl **lazy** |
| `graph_store.py` | `SpkgGraphStore` — embedded KùzuDB LPG (Entity/EDGE), `attack_path()` | kuzu (eager, needs `[spkg]`) |
| `build.py` | `build_spkg(capsule_dir, store)` — canonical RDF (SHACL-gated) **then** LPG rebuild from the same capsule | via the above |
| `entity_resolution.py` | `EntityResolver` — in-house Fellegi–Sunter cross-vendor linker (stdlib) | none |
| `detect.py` | `StructuralAnomalyDetector` + `to_findings()` — unsupervised edge-level scorer | **none (pure stdlib)** |

CLI surfaces (`cli/kg.py`): `nova kg build-provenance` (RDF export), `nova kg build` (populate both
stores), `nova kg detect` (anomaly scan). The first two need `pip install novafabric[spkg]`; **`detect`
needs no extra**.

Serve/dashboard surface (`serve/app.py`), read-only server-side parity for the CLI, behind the same token
+ localhost host guard as the other `/api/kg/*` routes:
- `POST /api/kg/detect` — mirrors `nova kg detect` (body `{capsule_path, top}` → `{ok, count, findings}`); no extra.
- `POST /api/kg/attack-path` — mirrors `nova kg attack-path` (body `{capsule_path, from_entity, to_entity, max_depth}` → `{ok, path_found, hops}`); needs `[spkg]`.
- `POST /api/kg/blast-radius` — mirrors `nova kg blast-radius` (body `{capsule_path, entity, upstream, max_depth}` → `{ok, direction, count, entities}`); needs `[spkg]`.

All resolve a bare `run_id` to its capsule dir. The dashboard React panels that consume these are a
follow-up built with the `packages/nova-dashboard` toolchain (not part of this Python slice).

**Two hard invariants** — do not break these:

1. **R2 — no bare score.** Every finding MUST carry a MITRE ATT&CK technique and/or a D3FEND
   countermeasure. This is enforced twice: the JSON `spkg-anomaly-finding-v1.schema.json` contract and
   the RDF `nf:FindingShape` SHACL constraint (`sh:or` over `nf:mapsToTechnique`/`nf:mapsToCountermeasure`).
   When you add a detector, emit findings via `to_findings()` / `finding_to_rdf()`; never invent a second
   finding shape.
2. **R11 — SHACL gate before write.** `build_spkg` validates the canonical PROV-O layer *before* touching
   the operational store; on failure it raises `SpkgValidationError` and leaves the LPG untouched. Any new
   ingest path must validate first.

**Keep the detector dependency-free.** The v0.1 `StructuralAnomalyDetector` is deliberately pure-stdlib so
`nova kg detect` runs on base `novafabric`. The PyGOD/TGN GNN upgrade is a **resource-gated** slice — it
pulls torch/torch_geometric, whose wheels bundle third-party components that need a full distribution-
license audit under [ADR-0024]. Do not add it without that audit and an ADR pointer.

**Adding a new finding type or detector:** implement scoring in a new module (or extend `detect.py`),
return findings through `to_findings()`, add tests under `tests/kg/` (assert the injected malicious edge
ranks top-k *and* the finding is schema/SHACL-valid), and — if user-facing — add a `nova kg <verb>`
command mirroring `detect` (import lazily; only guard with the `[spkg]` message if you actually need rdflib
/kuzu). Update `CHANGELOG.md` and `docs/cli-reference.md`.

---

## Standalone trust primitives (Python-API only)

A run of ADR-0101-adjacent ADRs (v0.75.0–v0.94.0) added several small, tested
trust modules that are each **pure Python API, with no `nova` CLI verb and no
dashboard panel**. They don't share a package — each lives next to the
subsystem it extends. Treat each as **experimental**: implemented and tested,
but no shipped end-user workflow wraps it yet. If you add a CLI or dashboard
surface for one, follow the "Adding a new CLI command" pattern above, mirror
the change into `docs/user-guide.md`'s v0.75–v0.94 cohort table, and note it in
`CHANGELOG.md`.

| Module | ADR | What it does |
|---|---|---|
| `trust/novaseal/x509_identity.py` | 0055 | Offline signing identity pinned to an x509 cert's SHA-256 fingerprint (ECDSA-P256/RSA-PSS) — verification checks `fingerprint ∈ pinned` **and** the signature. Optional **experimental** CA-bundle chain validation (`ca_bundle=`, `validate_certificate_chain`, via `cryptography.x509.verification`, offline) makes trust "pinned OR chain"; `nova verify --ca-bundle` applies it to the DSSE signer cert. |
| `trust/novaseal/hybrid_signature.py` | 0072 | Crypto-agility envelope over a pluggable algorithm registry — Ed25519 today, a post-quantum algorithm (ML-DSA) can register as a second signer later with no envelope format change ("either alone suffices"). |
| `trust/did.py` | 0075 | Self-certifying `did:key` (Ed25519, base58btc + multicodec, no network lookup) plus Verifiable Credential issue/verify. |
| `trust/delegation.py` | 0106 | Signed user → agent → sub-agent "acted-as" delegation chain (`issue_grant`, `verify_delegation_chain`) — scope only ever attenuates down the chain, never escalates. |
| `trust/novaseal/witness.py` | 0097 | Checkpoint + witness cosigning over the existing NovaSeal Merkle log's consistency proofs — anti-split-view; a second party corroborates the log hasn't forked. |
| `compliance/sovereignty.py` | 0077 | Jurisdiction site-seals (`issue_site_seal`/`verify_site_seal`) — an Ed25519 countersignature verified under the key registered for the *claimed* jurisdiction, so a forged residency claim fails verification; `check_cross_jurisdiction_read` enforces a `ResidencyPolicy`. |

None of these modules write to a capsule or mutate shipped schemas — they are
additive, standalone primitives you import directly. Tests live alongside the
existing subsystem's test tree, not a dedicated directory for this group:
`tests/seal/` for the three `trust/novaseal/` modules (`test_x509_identity.py`,
`test_hybrid_signature.py`, `test_witness.py`), `tests/trust/` for `did.py` and
`delegation.py`, and `tests/compliance/test_sovereignty.py` for the site-seal
module.

---

## Building the collector tier (Go, Phase 2)

> **Maturity: engineering toward planned architecture.** The Go collector tier targets
> the cluster-scale ingestion path documented as design intent in ADR-0020/0039. It is
> *not* part of the shipped local-first product surface. The shipped capture path needs
> no collector at all — see the capture data flow in
> [the architecture overview](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md).

The collector is a separate Go module at `collector/`; its `go.mod` declares `go 1.26.5`, so
you need Go 1.26.5 or newer. Install Go:

```bash
# Download Go 1.26.5 to ~/go/ (no root required)
curl -fsSL https://go.dev/dl/go1.26.5.linux-amd64.tar.gz | tar -xz -C ~
export PATH=$HOME/go/bin:$PATH   # add to ~/.bashrc for persistence
```

Build and test:

```bash
cd collector

# Download dependencies
GOPATH=~/gopath go mod tidy

# Run unit tests with race detector
GOPATH=~/gopath go test -race ./...

# Build all four binaries
GOPATH=~/gopath go build -o bin/novafabric-collector       ./cmd/novafabric-collector
GOPATH=~/gopath go build -o bin/novafabric-verifier        ./cmd/novafabric-verifier
GOPATH=~/gopath go build -o bin/novafabric-hpc-hub         ./cmd/novafabric-hpc-hub
GOPATH=~/gopath go build -o bin/novafabric-spool-forwarder ./cmd/novafabric-spool-forwarder

# Validate the 1000-event corpus
make spec-test
```

**Key collector packages:**

| Package | Description |
|---|---|
| `collector/pkg/canonical` | ADR-001 deterministic OTLP canonical encoding |
| `collector/pkg/novaseal` | Ed25519 signer, KMS client, LocalWAL dev fallback |
| `collector/pkg/envelope` | EventEnvelope Go types + OTLP / CloudEvents mappings |
| `collector/pkg/metrics` | Prometheus metrics registry |
| `collector/internal/spool` | Lustre-safe rename-commit JSONL spool (cap-001) |
| `collector/internal/processor/novasealbatchsigner` | OTel custom processor (cap-002) |
| `collector/internal/hpc` | NATS leaf lifecycle wrapper |
| `collector/internal/verifier` | Offline Ed25519 batch-signature verifier |

**Development mode (no KMS required):**

The local dev signer (`LocalWALKeystore`, `collector/pkg/novaseal/keystore_local_wal.go`) is
reachable from two places today: the collector's `novaseal_batch_signer` processor
(`local_wal: true`) and the verifier:

```bash
# verifier: --local-wal verifies against the same dev key
./bin/novafabric-verifier --batch batch.pb --local-wal
```

`novafabric-hpc-hub` does **not** use it: that binary is planned and exits `1` as not implemented.

On first use it generates an Ed25519 key at `~/.novafabric/dev-keys/<uuid>.pem` (mode 0400)
and logs a `USING LOCAL DEV KEY — NOT FOR PRODUCTION` warning; it refuses to run when
`NOVAFABRIC_ENV=production`.

> **Signer wiring in `novafabric-collector`.** The `novaseal_batch_signer` processor builds its
> signer from its config: `local_wal: true` uses the dev-key keystore; `keystore_endpoint` is
> refused (the KMS keystore needs an mTLS client certificate the config has no fields for yet —
> **planned**); with neither set it refuses to start unless `fail_open: true`, which forwards
> batches **unsigned** and says so at start. Releases up to v0.107.0 built no signer at all, so
> every batch was forwarded unsigned.

**Adding a new Go package:**

1. Create `collector/pkg/<name>/` or `collector/internal/<name>/`.
2. Write tests in `_test.go` files in the same package.
3. Run `GOPATH=~/gopath go test -race ./...` before committing.
4. Ensure all dependencies are Apache-2.0, MIT, or BSD-3 (per ADR-0024).
5. If the package introduces a new `nova_*` Prometheus metric, register it in `collector/pkg/metrics/metrics.go`.

**HPC integration tests** (require Docker):

```bash
cd collector/tests/integration
go test -tags=integration -v ./...
```

These tests require Docker + `docker compose` for the Slurm-in-Docker and 10-node NATS cluster harnesses. They are skipped in standard CI unless the `integration` build tag is set.

---

## Adding a framework adapter

Framework adapters live in `src/novafabric/adapters/`. Each adapter is a single
module that monkey-patches or wraps the framework's entry-point method to record
`nova capture`-compatible events. NovaFabric is framework-neutral — it *records* what
LangGraph/AutoGen/CrewAI/DSPy/OpenAI Agents SDK do rather than orchestrating them.

Pattern (using `crewai.py` as the model):

```python
# src/novafabric/adapters/myframework.py
from novafabric.capture.event_recorder import get_current_recorder

def wrap_thing(thing):
    original = thing.run

    def _wrapped(*args, **kwargs):
        recorder = get_current_recorder()
        try:
            result = original(*args, **kwargs)
            if recorder:
                recorder.record_event(...)
            return result
        except Exception:
            if recorder:
                recorder.record_event(...)
            raise

    thing.run = _wrapped
    return thing
```

Rules:
1. Fail-open: never raise inside the wrapper; log and swallow.
2. Use `get_current_recorder()` — never instantiate a new recorder.
3. Restore the original method on `unwrap()` if reversibility matters.
4. Add an entry to `src/novafabric/adapters/__init__.py`.
5. Add the framework to the adapter roster in
   [`docs/for-platform-teams.md`](https://novafabric.ai/docs/for-platform-teams/) and note it in
   [`docs/architecture.md`](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md).

### Two kinds of adapter

The pattern above is for an adapter that records **into a capsule someone else
opened** — `nova capture` is already running and the adapter adds framework
detail to it.

An adapter can instead **own the capsule**, so that calling the wrapped object
is all a user has to do. Those wrap the framework's entry point and write a
complete manifest around it: `dspy.py`, `crewai.py`, `llamaindex.py`,
`pydantic_ai.py`, `haystack.py`.

New capsule-writing adapters should use the shared core rather than copying a
manifest literal. Eight of the adapters were written before it (`a2a`, `autogen`,
`bedrock_agentcore`, `crewai`, `dspy`, `google_adk`, `langgraph`,
`openai_agents`), and each carries its own copy, which is how all eight of them
once drifted into writing a `tags` key the schema rejects:

```python
from novafabric.adapters._capsule import begin_capture, require

def wrap_thing(thing, *, run_name=None, data_dir=None):
    require("myframework", "my-framework")        # ImportError with an install hint
    original = thing.run

    def _wrapped(*args, **kwargs):
        cap = begin_capture(framework="myframework",
                            run_name=run_name or type(thing).__name__,
                            data_dir=data_dir)
        try:
            return original(*args, **kwargs)
        except Exception as exc:
            cap.fail(exc)                          # records it; does not swallow
            raise
        finally:
            cap.finish()                           # releases hooks, writes the capsule

    thing.run = _wrapped
    return thing
```

`begin_capture` claims the wire hooks and `finish` stamps
`metadata.wire_capture` from the returned token before releasing them, which is
what ADR-0224 requires — a capsule has to say whether its wire stream is
complete. Getting that ordering wrong is the single easiest mistake here.

**Watch for a framework whose sync entry point drives its async one** (Pydantic
AI's `run_sync` calls `run`; Haystack 2.x's `AsyncPipeline.run` calls `run_async`).
Patching both without a re-entrancy guard opens two capsules for one user-visible
call, and the inner one takes the wire hooks from the outer, so neither stream is
complete. `pydantic_ai.py` and `haystack.py` show the guard.

**Watch for an entry point that returns before the work runs.** LlamaIndex's
agent `run` returns a `WorkflowHandler` whose work runs in a background task — an
`asyncio.Future` in llama-index-workflows 1.x, a plain awaitable in 2.x, so an
`isinstance(…, Future)` check alone misses every current install. Streaming entry
points are the same shape: the model call happens while the caller iterates a
generator (LlamaIndex `response_gen`) or inside an `async with` body (Pydantic AI
`run_stream` / `iter`). Finishing the capsule when the method returns writes an
empty, successful capsule and releases the hooks before the first model call.
`adapters/_streaming.py` holds the helpers that move the close to where the run
really ends and record how it ended (`success` / `failure` / `partial` with
`metadata.partial_reason`); `AdapterCapture.mark_partial()` sets the schema's
`partial` status. `Closer` takes any object with `fail` / `mark_partial` / `finish`
(the `ClosableRun` protocol), so an adapter with its own capsule writer — `langgraph.py`'s
`_GraphRun` — reuses the same guards. Check the shape against the framework's *installed* source, not
its docs — and test the abandoned and dropped-unread cases, not just the happy one.

### Typed `record_*` methods (extended event taxonomy, ADR-0082)

Beyond the generic recorder, `EventRecorder` exposes typed convenience methods
for the extended span taxonomy (ADR-0082). Each validates a Pydantic model,
appends one line to a dedicated JSONL stream tagged with an `event_type`
discriminator, and is **fail-open** — a bookkeeping failure never reaches the
agent workflow:

| Method | JSONL stream | Event type |
|---|---|---|
| `record_file_event` | `file_events.jsonl` | `FileEvent` |
| `record_network_event` | `network_events.jsonl` | `NetworkEvent` |
| `record_human_approval` | `human_approvals.jsonl` | `HumanApprovalEvent` |
| `record_state_transition` | `state_transitions.jsonl` | `StateTransition` |
| `record_memory_operation` | `memory_operations.jsonl` | `MemoryOperation` |
| `record_guardrail` | `guardrail_events.jsonl` | `GuardrailEvaluated` |
| `record_evaluator` | `evaluator_events.jsonl` | `EvaluatorScored` |
| `record_reranker` | `reranker_events.jsonl` | `RerankerApplied` |
| `record_vector_retrieval` | `vector_retrievals.jsonl` | `VectorRetrieval{Started,Completed,Failed}` (by `phase`) |

```python
recorder = get_current_recorder()
if recorder:
    recorder.record_guardrail("toxicity-filter", outcome="passed", score=0.02)
    recorder.record_vector_retrieval("pgvector", phase="completed", top_k=5, returned_count=5)
```

---

## Live Topology Dashboard development

> **Maturity: experimental.** The live topology view ships behind
> `nova serve --experimental --topology` (Louvain cluster view + TDP WebSocket; it also
> enables the TV-5 3D view, and `--topology-louvain-resolution` tunes the clustering). Like
> the rest of `nova serve`, it is local-only, and the server is **not** read-only — it carries
> Layer B/C mutation routes behind the scope table (see [api-reference](https://novafabric.ai/docs/api-reference/)).
> Present it as experimental, not as a stable feature.

The topology dashboard has two separate development loops.

### Python server-side

Topology modules live in `src/novafabric/serve/topology/`. Run tests with:

```bash
uv run pytest tests/test_topology_*.py -v
```

To start the server in topology mode:

```bash
make serve-topology   # build SPA + start nova serve --experimental --topology
```

### TypeScript SPA (`packages/nova-dashboard/`)

```bash
cd packages/nova-dashboard
npm install
npm run dev        # Vite dev server with HMR (proxies API to nova serve)
npm test           # vitest unit tests
npm run build      # production build, written straight to src/novafabric/serve/static/topology/
```

`vite.config.ts` sets `build.outDir` to `../../src/novafabric/serve/static/topology`, so a
production build needs no copy step; `make topology-build` is the same `npm run build` run
from the repository root.

The SPA talks to the Python server at the same origin. In dev mode, configure
`vite.config.ts` proxy to point at your running `nova serve` instance.

## TV-5 3D Topology View development

> **Maturity: engineering toward planned architecture** — same caveat as the 2D
> topology dashboard above.

TV-5 adds a Three.js 3D topology view alongside the existing 2D Sigma.js view.

### Python server-side (TV-5)

TV-5 modules live in `src/novafabric/serve/topology/`. Run dedicated tests with:

```bash
uv run pytest tests/tv5/ -v
```

The three TV-5 modules are:

- `snapshot_store_3d.py` — `SnapshotStore3D`: atomic fine/coarse snapshot tiers (msgpack or JSON)
- `layout_pipeline_3d.py` — `LayoutPipeline3D`: `networkx.spring_layout` 3D layout in `ProcessPoolExecutor`
- `router_tv5.py` — FastAPI router factory `make_tv5_router()`: `/api/tv5/` REST + WebSocket

To start the server with TV-5 enabled:

```bash
nova serve --experimental --tv5
# Or with the 2D topology dashboard too:
nova serve --experimental --topology --tv5
```

Environment variables:

- `TV5_SNAPSHOT_DIR` — storage path (default `.nova/tv5_snapshots`)
- `TV5_MAX_FINE_SNAPSHOTS` — retention count for fine tier (default 288)
- `TV5_MAX_COARSE_SNAPSHOTS` — retention count for coarse tier (default 168)

The optional `msgpack` package enables binary snapshot transport. Without it, JSON is used automatically.

### TypeScript SPA (TV-5)

TV-5 adds one new component to the nova-dashboard SPA:

- `src/components/topology/TV5Panel.tsx` — Three.js `InstancedMesh` per node type, `LineSegments` edges, `OrbitControls`, time-slider, WS live feed, p99 health color encoding

The existing `src/App.tsx` has a tab switcher: **2D View** (Sigma.js, existing) / **3D View (TV-5)** (Three.js, new).

```bash
cd packages/nova-dashboard
npm install    # also installs three, @react-three/fiber, @react-three/drei
npm test       # includes TV5Panel.test.ts
npm run build  # produces static bundle including TV5Panel
```

---

## Warm capture daemon development (daemon/, ADR-0092)

The warm capture daemon (`src/novafabric/daemon/`) is a **prefork** `AF_UNIX`
server: the parent imports `novafabric` once, then `os.fork()`s one worker per
run. User-facing docs are in [`docs/warm-capture-daemon.md`](https://novafabric.ai/docs/warm-capture-daemon/);
this section is for working on the daemon itself.

Module layout:

| File | Responsibility |
|---|---|
| `protocol.py` | length-prefixed JSON frames + SCM_RIGHTS fd passing (server/worker side) |
| `client.py` | **stdlib-only** thin client (`novacap`); must not import the `novafabric` package |
| `server.py` | forking server: peercred check, request dispatch, fork, SIGCHLD reaper |
| `worker.py` | post-fork: dup2 stdio, `setpgrp`, run `CaptureOrchestrator`, cancel watcher |

Hard rules when editing here:

- **The prefork model depends on `import novafabric` starting no background
  threads.** A fork after a thread is started corrupts the child. There is a gate
  test for this — `tests/daemon/test_fork_safety_preflight.py`. If you add an
  import-time thread anywhere in the import graph, that test fails and the daemon
  is unsafe; make the thread lazy instead.
- **`client.py` must stay stdlib-only.** Importing it must not pull in the
  `novafabric` package, or the thin client re-acquires the cold-start the daemon
  exists to remove. It carries its own small copies of the frame helpers. There is
  no test that mechanically enforces this — keep its imports to the standard
  library by hand.
- **`run_worker` runs only in a forked child.** It rebinds stdio, calls
  `setpgrp`, and may `killpg` its own process group — never call it in-process. It
  is exercised for real (via an explicit `os.fork`) in
  `tests/daemon/test_worker_integration.py` and end-to-end in
  `tests/daemon/test_fidelity_e2e.py`; fork/loop-only branches are marked
  `# pragma: no cover` with a pointer to those tests.
- **Fidelity is the contract.** The worker must run the existing
  `CaptureOrchestrator` unchanged so a daemon capsule stays structurally identical
  to a direct one. `tests/daemon/test_fidelity_e2e.py` enforces this — keep it green.

To extend the protocol (e.g. a new `op`), add it in `server._handle` (the parent
reads the first frame and decides whether to fork) and mirror any client-side
framing in `client.py`. Liveness probes are a bare connect that the parent reads as
`None` and closes — they must never fork.

---

## Extension points

These are the plug-in surfaces that let you integrate NovaFabric with your own
infrastructure **without forking core**. Each is a `typing.Protocol` or an entry-point
group with a stable contract.

### BypassNotifier — bypass event dispatch

Implement the `BypassNotifier` Protocol (`src/novafabric/promote/bypass_notify.py`) to
route maker-checker bypass events to any destination:

```python
from novafabric.promote.bypass_notify import BypassEvent, BypassNotifier

class SlackBypassNotifier:
    def notify(self, event: BypassEvent) -> None:
        # post to Slack; raise BypassNotifyError (or any exception) on failure
        ...
```

Wire it via `PromoteBundleStore(base_dir, notifier=SlackBypassNotifier())` (`base_dir` is
required). Without a
`notifier`, the store uses `build_notifier_from_env()`: `NOVA_BYPASS_NOTIFY_FILE` /
`NOVA_BYPASS_NOTIFY_WEBHOOK` for the built-in backends. A notifier may raise: the
store calls it through `dispatch_bypass_event()`, which never lets a failure block
the bypass, logs it, and records a `seal.bypass.notify` audit entry
([novaseal-configuration §4.1](https://novafabric.ai/docs/novaseal-configuration/#41-maker-checker-bypass-notifications)).
A custom notifier counts as a configured channel: its class name is signed into the
bypass predicate's `notification_sent_to` (status `pending`). Build the predicate's
notification fields from `PromoteBundleStore.notification_channels()` before signing, as
`nova seal bypass` does. `put_bypass()` raises `BypassAuditError`, and stores nothing, when its
`seal.bypass.create` audit entry cannot be written.

### SigningBackend — Cloud KMS signing

Implement the `SigningBackend` Protocol (`src/novafabric/trust/novaseal/signing_backend.py`)
to integrate any key management system:

```python
from novafabric.trust.novaseal.signing_backend import SigningBackend

class VaultSigningBackend:
    def sign_digest(self, digest: bytes) -> bytes:
        """Sign a SHA-256 digest; return a DER-encoded ECDSA P-256 signature."""
        ...

    def get_cert_der(self) -> bytes:
        """Return the DER-encoded X.509 certificate that identifies the key."""
        ...
```

Pass the backend to `NovaSeal(config=..., backend=VaultSigningBackend())`, or at the lower
level to `create_envelope(payload, key_path, cert_path, backend=VaultSigningBackend())` —
`key_path` and `cert_path` are still positional parameters there, but are ignored when a
backend is given.
Built-in cloud backends: `AwsKmsSigningBackend`, `AzureKvSigningBackend`,
`GcpKmsSigningBackend` — activated via `build_signing_backend(profile)`.

Install the relevant optional extra:
- `pip install novafabric[seal-aws]` — AWS KMS (boto3)
- `pip install novafabric[seal-azure]` — Azure Key Vault
- `pip install novafabric[seal-gcp]` — GCP Cloud KMS

### ZstdDictRegistry — OCS compression dictionaries

Use `ZstdDictRegistry` (`src/novafabric/object_capsule_store/zstd_dict.py`) to train
and register named zstd dictionaries for capsule compression:

```python
from novafabric.object_capsule_store import ZstdDictRegistry

from novafabric.object_capsule_store import ObjectCapsuleStore, ZstdDictRegistry

reg = ZstdDictRegistry()
reg.train(samples=[sample1, sample2, ...], dict_id="model-weights-v1")  # returns the dict bytes
store = ObjectCapsuleStore(adapter, novaseal_client, wal, zstd_registry=reg)
receipt = store.put_capsule(
    tenant="acme", run_id=run_id, content_bytes=capsule_bytes,
    compression_dict_id="model-weights-v1",
)  # synchronous; returns a CapsuleReceipt
```

`ObjectCapsuleStore(adapter, novaseal_client, wal, retention_days=1825, ...)` needs a WORM
adapter, a NovaSeal client and a `LocalWal` (see `object_capsule_store/client.py`); a dictionary
trained elsewhere can be loaded with `reg.register(dict_id, dict_bytes)`.

Install with `pip install novafabric[ocs-compress]`.

### JanusGraph lineage backend

Use `JanusGraphLineageStore` for cluster-scale lineage storage:

```python
from novafabric.lineage.backends.janusgraph import JanusGraphLineageStore

from novafabric.lineage._types import LineageEdge

store = JanusGraphLineageStore(gremlin_endpoint="ws://janusgraph:8182/gremlin")
store.insert(LineageEdge(
    edge_type="replayed_from",
    source={"kind": "run", "run_id": "run-b"},
    target={"kind": "run", "run_id": "run-a"},
    confidence="declared",
    capsule_run_id="run-b",
))
nodes = store.provenance("run-b", depth=3)  # nodes reachable along out-edges within 3 hops
```

`JanusGraphLineageStore(gremlin_endpoint="ws://localhost:8182/gremlin", site_id="local")`
implements `insert`, `provenance`, `blast_radius` and `replay_chain`. LDBC SNB BI queries via
`novafabric.lineage.backends.janusgraph_snb.LineageSnbQueries(store)`; deploy the included Helm chart at
`deploy/helm/janusgraph/`. Install with `pip install novafabric[janusgraph]`.

> **Note.** SQLite is the default local-mode lineage store and needs no network; it is
> the source-of-truth-derived, rebuildable cache. Alternative backends like this one are
> strictly additive — local mode never requires them.

### AGE lineage backend — docker-compose profile (experimental)

Use `AGELineageStore` (`src/novafabric/lineage/backends/age.py`) for the Apache AGE
(openCypher-over-Postgres) lineage backend — a third behavioural peer of
`SqliteLineageStore`/`PostgresLineageStore`/`KuzuLineageStore`, correctness-parity-tested
against the SQLite reference via testcontainers (`tests/lineage/test_age_backend.py`):

```python
from novafabric.lineage.backends.age import AGELineageStore

store = AGELineageStore(dsn="postgresql://nova:nova@localhost:5433/nova_lineage")
```

**Experimental:** a dedicated `age` docker-compose profile lets you run Apache AGE
locally without pulling in testcontainers:

```bash
make age-up      # docker compose --profile age up -d age — starts ONLY novafabric-age on :5433
make age-down     # docker compose --profile age stop age && rm -f age — removes ONLY that container
```

Both targets name the `age` service explicitly on every command and never call bare
`up`/`down` — `docker compose --profile age config --services` resolves to the union
`{age, postgres, nova}` (the latter two have no `profiles:` key, so they're always
"active" under any profile filter), and a bare `up`/`down` would start or tear down
that whole union. Naming `age` explicitly scopes every command to that one container;
`make age-up`/`make age-down` never touch an already-running dev/prod stack's
`novafabric-postgres` or `novafabric-serve` (verified live).

DSN: `postgresql://nova:nova@localhost:5433/nova_lineage`. `AGELineageStore.__init__`
self-initializes the extension (`CREATE EXTENSION IF NOT EXISTS age`) and the graph
(`create_graph`) on first connect — no manual setup or init-SQL mount step is required.
Connecting from Python needs the `[server]` extra for `psycopg[binary]`:
`pip install novafabric[server]`.

> **Note.** This is a genuinely separate Postgres instance from the MetadataStore's
> `postgres` service (port 5432) — the AGE extension is not present in plain
> `postgres:16-alpine`, and the lineage graph is a derived/rebuildable artifact that
> deliberately does not share the metadata database. This docker-compose profile is
> **experimental** and strictly opt-in — it is absent from both the default dev stack
> and `prod`.

`make age-down` deliberately does not pass `-v`, so the named `age-data` volume
(`docker_age-data`, or `<project>_age-data` if you've overridden the compose project
name) is preserved across restarts — the same convention as `dev-down`/`prod-down`
preserving `pg-data`/`kuzu-data`. To discard the AGE data volume entirely (e.g. after
experimenting with the backend), remove it explicitly and intentionally:
`docker volume rm docker_age-data` (confirm the exact name first with
`docker volume ls | grep age-data` — the prefix depends on your compose project name).

### NovaPySpool — Python cffi spool binding

`NovaPySpool` (`src/novafabric/collector_cffi/spool.py`) provides a Python-native spool
compatible with the Go collector's binary segment format. When `libnovaspool.so` is
present it binds via cffi; otherwise it falls back to a pure-Python writer with the same rules
as the Go one: each segment is committed with `link(2)` (never replacing another process's
segment), sequence numbers start above every committed segment and the forwarder's
`checkpoint.json`, and only segments the checkpoint acknowledges are evicted — a spool already
holding `max_files` unforwarded segments raises `SpoolFullError` (an `OSError`) instead of
deleting them. Releases up to v0.107.0 evicted unforwarded events silently at 100 segments and
let two writers overwrite each other's segments:

```python
from novafabric.collector_cffi.spool import NovaPySpool

spool = NovaPySpool(Path("/var/nova/spool"), max_files=1000)  # default DEFAULT_MAX_FILES (10 000)
spool.write(b'{"event":"capture","ts":1234567890}')
spool.drain()  # returns None: a flush signal (a no-op on the pure-Python path,
               # where every write() is already durable when it returns)
```

There is no `pip install`-able extra for this — `libnovaspool.so` is a Go shared
library built out-of-band by a separate Go toolchain step (see
`src/novafabric/collector_cffi/spool.py`'s module docstring for the `go build`
invocation), not something `pyproject.toml` can express or produce. The `cffi`
binding layer itself needs no extra either: it comes in transitively via the
core `cryptography` dependency on CPython, so `NovaPySpool` is importable out
of the box. When the `.so` isn't present (or fails to load) it automatically
falls back to the pure-Python atomic-rename backend — no extra install step
is required either way.

---

## Extending the Capsule Knowledge Graph

The KG schema has 5 node tables and 4 relationship tables.  To add a new node type:

1. Add `CREATE NODE TABLE IF NOT EXISTS <Type> (id STRING PRIMARY KEY, …)` to the
   module-level `SCHEMA_DDL` list in `kg/store.py` (`KGStore.init_schema()` runs it).
2. Add a `merge_<type>()` method on `KGStore` and the corresponding upsert logic.
3. Register the type in `EntityNormaliser.normalise()` dispatch (`kg/entity_normaliser.py`).
4. Add extraction logic in `KGIngestionPipeline.ingest_event()` (`kg/pipeline.py`), resolving
   each raw name through `_resolve_entity()`.
5. Include the new type in `get_node_counts()` / `get_topology_graph()` in `kg/store.py`.
6. Add the type to `KGTopology` in `ui/dashboard/src/lib/api.ts` and update `TopologyLayerPanel`
   in `ui/dashboard/src/components/dashboard/tabs/KGTab.tsx`.

**MCP server auto-detection pattern:** tool names containing `:` are
split on the first `:` in `ingest_event()`.  The left part becomes the `MCPServer`
name; the right part is the `Tool` name.  A `SERVED_BY` (Tool → MCPServer) edge is added
in addition to the standard `USES_TOOL` (Agent → Tool) edge.  Follow this pattern when
adding other auto-detection heuristics.

### `nova kg alias register --type mcp_server`

The alias table accepts `mcp_server` as an entity type (alongside `model`, `agent`,
`tool`, `endpoint`).  Register a display-name alias for a raw MCP server prefix:

```bash
nova kg alias register "filesystem" "nova-mcp-filesystem" --type mcp_server
```

### `nova kg query` MCP server output

`nova kg query <agent-id>` includes a `mcp_servers` key in the JSON result and
prints a "MCP servers reachable" section in text mode.  The 2-hop path is
`Agent → Tool → MCPServer` via `USES_TOOL` + `SERVED_BY`.

### KG auto-ingest configuration

The `nova serve` background loop polls for new capsules.  Two environment variables
control its behaviour:

| Variable | Default | Description |
|---|---|---|
| `NOVA_KG_INGEST_INTERVAL` | `60` | Poll interval in seconds |
| `NOVA_KG_PATH` | `<capsule_dir>/.nova/kg/nova_kg.kuzu` (under the `nova serve` capsule directory) | KuzuDB file path. The `nova kg` CLI reads the same variable for `--path`, but its own default is `.nova/kg/nova_kg.kuzu` relative to the **working directory** — set `NOVA_KG_PATH` so both open one graph. |

The set of already-ingested capsule directories is persisted in a SQLite sidecar
(`ingest_tracker.db`) so restarts don't re-process the entire history.  The tracker
is implemented in `src/novafabric/kg/ingest_tracker.py` (`IngestTracker` class).

### Topology cache

`GET /api/kg/topology` caches its response for 30 seconds.  The cache is
process-local (not shared across workers) and is invalidated on process restart.
Dashboard `TopologyLayerPanel` is lazy-loaded — it fetches only when the user clicks
"Load topology", not on panel mount.

## Extending CapsuleWatcher backends

`CapsuleWatcher` (`src/novafabric/serve/capsule_watcher.py`) abstracts capsule
directory scanning behind `_BackendProtocol` (a `typing.Protocol`):

```python
import sqlite3
from pathlib import Path

class MyCustomBackend:
    """Example: watch an NFS mount or remote S3-prefix list."""

    @property
    def name(self) -> str:
        return "my-custom"

    def poll_once(self, capsule_dir: Path, conn: sqlite3.Connection) -> int:
        """Scan for new capsules and upsert them into runs_cache. Return count ingested."""
        ...

    def close(self) -> None:
        """Release resources (inotify watches, file handles, etc.)."""
        ...
```

**A custom backend cannot be injected today.** `CapsuleWatcher(capsule_dir, db_path=None,
interval=None, backend=None)` takes `backend` as a **string** — `auto`, `polling` or
`watchdog` — and builds the backend object itself; any other value raises
`WatcherConfigError`. Adding a backend means adding a class that satisfies
`_BackendProtocol` above, a name in `VALID_BACKENDS`, and a branch in
`CapsuleWatcher.__init__`. With a built-in backend, call `watcher.ingest_all()` /
`watcher.poll_once()` / `watcher.start_background()`.

Built-in backends:

| Backend | Module | How selected |
|---|---|---|
| `PollingBackend` | `capsule_watcher.py` | default (`auto` when watchdog absent) |
| `WatchdogBackend` | `capsule_watcher.py` | `NOVA_WATCHER_BACKEND=watchdog` (or `nova ingest-capsule --backend watchdog`); `auto` picks it when watchdog is installed; requires `pip install novafabric[watch]` |

`nova serve` instantiates `CapsuleWatcher` with the backend resolved from
`NOVA_WATCHER_BACKEND` (default: `auto`).  The poll interval is controlled by
`NOVA_WATCHER_INTERVAL` (default: `2.0` s). `resolve_watcher_settings()` applies one
precedence everywhere: an explicit argument beats the environment, which beats the default.
`nova serve` has no watcher flags, so it always reads the environment;
`nova ingest-capsule --backend/--interval` is the CLI that passes explicit values. An unknown backend or a non-positive interval raises
`WatcherConfigError`; `create_app()` resolves the settings eagerly so the error surfaces at
startup rather than in the background refresh loop.

## Registering a third-party eval suite adapter

Eval suites are discovered at runtime via the `novafabric.eval_suites` entry-point group. To ship a custom suite in your own package, implement `EvalSuiteAdapter` and register it:

```python
# my_package/my_suite.py
from pathlib import Path
from novafabric.evals.adapter import EvalSuiteAdapter
from novafabric.evals.result import EvalResult

class MySuiteAdapter:
    def suite_id(self) -> str:
        return "my-suite-v1"

    def version(self) -> str:
        return "1.0.0"

    def oci_digest(self) -> str:
        return ""  # "" = host-env; "sha256:<hex>" = OCI-pinned

    def run(self, capsule_path: Path, config: dict[str, str]) -> EvalResult:
        ...
```

In `pyproject.toml`:

```toml
[project.entry-points."novafabric.eval_suites"]
my-suite-v1 = "my_package.my_suite:MySuiteAdapter"
```

After `pip install -e .`, `nova eval list` will show the new suite. `nova eval run <capsule> --suite my-suite-v1` will invoke it.

The built-in suites (GAIA, SWE-bench, AgentBench, MMLU, Smoke) ship as OCI-pinned
containers and can gate promotion on regression via OPA/Rego — see the eval sections of
[`docs/cli-reference.md`](https://novafabric.ai/docs/cli-reference/).

## Writing a custom PII masker (ADR-0135) — experimental

Capture-time redaction is extensible: operator-registered **maskers** run at
capture **after** the built-in [ADR-0009](https://novafabric.ai/docs/decisions/)
secret scanner and **before** the capsule is finalized. Built-ins always run and
can never be disabled by a plugin. Use a masker for imperative masking logic that
declarative regex packs cannot express — checksum-validated national IDs, internal
case numbers, format-preserving tokenizers.

A masker is a pure, deterministic, **offline** callable — no sockets, no capsule
writes; it returns a value, the pipeline writes and proves:

```python
# my_package/maskers.py
import re
from novafabric.masking import UNCHANGED, MaskContext, MaskField

class CaseIdMasker:
    masker_id = "acme-case-id"      # stable identity, attributed in the proof
    masker_version = "1"            # bump on behavior change
    pattern_ids = ("acme-case-number",)

    _RE = re.compile(r"ACME-CASE-\d+")

    def mask(self, field: MaskField, value: str, context: MaskContext) -> str | object:
        masked = self._RE.sub("[MASKED:acme-case-id]", value)
        return UNCHANGED if masked == value else masked   # decline with UNCHANGED
```

Register it either as a `novafabric.maskers` entry point:

```toml
[project.entry-points."novafabric.maskers"]
acme-case-id = "my_package.maskers:CaseIdMasker"
```

or by dotted import path in `.novafabric/masking.yaml` (auto-discovered by
`nova capture`; override with `--masking-config PATH`):

```yaml
masking:
  enabled: true
  maskers:
    - id: acme-case-id            # entry-point name or my_package.maskers:CaseIdMasker
      version: "1"
      timeout_ms: 50              # per-call budget; exceeding it fail-closes the field
      max_input_bytes: 65536      # per-call input cap
      on_error: redact            # redact (default) | drop — the fail-closed action
      config:                     # opaque, surfaced as context.masker_config
        prefix: "ACME-CASE"
```

Failure semantics (the contract your masker lives under):

- **Built-ins first, always.** Your masker observes already-redacted markers; it can
  never un-redact them.
- **Same files as the built-in scanner.** Your masker is offered every string value
  of the call and event streams, `env.lock`, `assets.jsonl`, `lineage.jsonl` and the
  manifest (before `capsule.yaml` is written), and every *line* of each text file
  under `inputs/` and `outputs/` (`target_ref` `outputs/stdout.txt#L3`). Binary files
  and files over the 64 MiB scan limit are not offered to maskers; the built-in
  scanner still covers them, streaming a file that large in 8 MiB windows that
  overlap by 64 KiB, so it is never held in memory. A text file is redacted in
  place exactly as a small one would be; a binary that carries a secret is
  dropped. Residual: a single match longer than 64 KiB, or a cut forced inside a
  whitespace-free run longer than 32 MiB, may differ at that one point. Releases
  up to v0.107.0 skipped such files entirely and sealed them unscanned.
- **Built-ins last, too.** After every masker has run, a final built-in pass
  rescans the finished capsule. A masker whose output looks like a key cannot leak
  it: the pass redacts it and records it in `residual_check`.
- **Fail-closed on secrets.** If your masker raises, times out, exceeds the input cap,
  returns a non-string, or returns output still containing the raw value, the field is
  redacted (or dropped, per `on_error`) and the failure is recorded in the proof's
  `masker_errors[]`. The raw value is never written.
- **Fail-safe for the workload.** A masker failure never crashes or blocks the captured
  command; capture continues. A masker that cannot be *loaded*, however, aborts capture
  before the workload starts — expected masking is never silently skipped.
- **Every mask is evidence.** Each applied mask emits a `masker_findings[]` entry in
  `redaction-proof.json` (`masker_id`, `pattern_id`, `target_ref`, `match_hash` of the
  pre-mask bytes, `chain_position`); the proof stays hash-chained. `match_hash` is an
  HMAC-SHA256 under a key generated for each capture and never stored: findings that
  masked the same value within one capsule share it, but it cannot be matched against a
  candidate afterwards or linked to another capsule. A masked value is often the PII
  itself (`user.email`), and a plain SHA-256 of it, sealed into a WORM capsule, could be
  reversed by dictionary and would outlive `nova pii erase`. Releases up to v0.107.0
  stored that plain digest.

Reference implementation: `novafabric.masking.examples.EmailMasker` (registered as
`novafabric-email`). See [ADR-0135](https://novafabric.ai/docs/decisions/) for the masking-pipeline decision; schemas:
`schemas/masking-config.schema.json`, `schemas/masker-finding.schema.json`,
`schemas/masker-error.schema.json`. ML-based PII detectors (e.g. Presidio) are **not**
bundled — an external masker package may wrap one, but it must stay offline and clear
the [ADR-0024](https://novafabric.ai/docs/decisions/) license audit.

## Querying and extending PolicyStore

`novafabric.promote.policy_store.PolicyStore` stores signed promotion policy bundles in a SQLite DB. Key methods:

| Method | Description |
|---|---|
| `put(bundle_json, namespace)` | Insert a new policy bundle; returns version number |
| `get_latest(namespace)` | Return `bundle_json` for the highest-version policy |
| `get_by_version(version, namespace)` | Return `bundle_json` for a specific version |
| `get_active_at(timestamp, namespace)` | Return the policy active at a given UTC timestamp |
| `list_all(namespace=None)` | Return all rows as `list[dict]`; optional namespace filter |

Every default policy-store location — `nova policy sign` / `list`, `nova seal propose` / `verify`, `nova export-model-independence --policy-db`, `POST /api/policy/sign`, `GET /api/policy/list`, the SealTab and server mode — comes from `novafabric.promote.policy_location.default_policy_db()`, which is the NovaSeal Merkle log — `NOVAFABRIC_SEAL_DB_PATH`, then `merkle_db` in `novaseal.yaml`, then `$NOVAFABRIC_HOME/novaseal-merkle.db`. A Postgres log raises `PolicyDbLocationError` (the store is SQLite). `legacy_policy_db()` returns the pre-2026-10-23 default `~/.local/share/novafabric/merkle.db` when it exists, for the hint commands print.

---

## Docs-honesty and maturity labels

When you touch any documentation surface, apply NovaFabric's four-label rule. Every
mention of a feature must be classifiable as exactly one of:

| Label | Meaning |
|---|---|
| **works today** | Implemented in main, tests pass, part of the shipped surface. |
| **experimental** | Implemented but unstable — the interface may change before the v1.0 schema freeze. |
| **planned** | On the roadmap with a target version; design intent captured in an ADR. |
| **future design** | Documented intent only, no implementation. |

Two hard rules that apply throughout this guide:

1. **Never claim a planned or experimental subsystem as a stable, shipped product
   feature in user-facing docs.** In-tree code can exist and have tests while the
   corresponding *product capability* is still experimental or planned — keep the two
   distinct. The scale-out subsystems in this guide (collector tier, topology
   dashboards, object capsule store) are engineering work toward planned architecture,
   not released features.
2. **NovaFabric produces evidence that *supports* compliance; it does not certify
   compliance.** It attests only that a capsule is unmodified since signing. Do not
   describe any exporter or profile as making a run "compliant" with the EU AI Act,
   NIST AI RMF, ISO/IEC 42001, GDPR, HIPAA, FDA 21 CFR Part 11, SOC 2, or any other
   framework.

The standard for this voice is the README's [when *not* to use NovaFabric](https://github.com/MSKazemi/novafabric/blob/main/README.md#when-to-use-novafabric)
section: state the limitation plainly, in the same sentence as the capability.

---

## Adding a database migration

There are two independent Alembic **universes** (ADR-0211), each with a SQLite and a
Postgres track — four version trees that share no revision graph. `nova db upgrade` drives
them: `--track metadata|registry` picks the universe (default `metadata`), `--backend
sqlite|postgres` the database (default `sqlite`). Full table:
[architecture — schema migrations](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md#schema-migrations).

| Universe | Track | Config | Versions |
|---|---|---|---|
| **MetadataStore** (run metadata tier) | SQLite | `src/novafabric/metadata_store/migrations/sqlite/alembic.ini` | `src/novafabric/metadata_store/migrations/sqlite/versions/` |
| | Postgres | `src/novafabric/metadata_store/migrations/postgres/alembic.ini` | `src/novafabric/metadata_store/migrations/postgres/versions/` |
| **Registry** (experimental; `assets`, `eval_results`, `lineage_*`) | SQLite | `alembic.ini` | `alembic/sqlite/versions/` |
| | Postgres | `alembic-postgres.ini` | `alembic/postgres/versions/` |

From a checkout, the registry universe can also be driven with Alembic directly:

```bash
alembic revision -m "add foo column"                             # registry, SQLite
alembic -c alembic-postgres.ini revision -m "add foo column"     # registry, Postgres
```

A change that affects both databases of a universe needs a revision in **both** of its
trees, and a change to the other universe needs its own revisions.

**Touching `runs` in the MetadataStore Postgres track?** It is partitioned by `started_at`, so
any unique constraint must include that column, and its key is
`(run_id, tenant_id, started_at)` (ADR-0226). `register_run` keeps the documented
`(run_id, tenant_id)` idempotency with a `WHERE NOT EXISTS` guard rather than an
`ON CONFLICT` clause — see [architecture](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md#schema-migrations). The registry
universe is also shipped inside the wheel: the repo-root `alembic/` tree is mapped to
`novafabric/migrations/registry` at build time (`[tool.hatch.build.targets.wheel.force-include]`
in `pyproject.toml`; there is no such directory under `src/`), so an installed CLI can migrate
without the repository present — if you add a registry revision, confirm it is packaged.

**Always pass an ordinary libpq DSN.** `alembic/env.py` routes it through
`novafabric.metadata_store.dsn.to_sqlalchemy_url`, which rewrites a bare
`postgresql://` to `postgresql+psycopg://`:

```bash
NOVAFABRIC_POSTGRES_DSN=postgresql://user:pass@localhost:5432/nova \
  alembic -c alembic-postgres.ini upgrade head
```

SQLAlchemy resolves the bare scheme to **psycopg2**, which this project does not
ship — it depends on `psycopg[binary]` (psycopg 3). Without that normalisation the
command above dies with `ModuleNotFoundError: No module named 'psycopg2'`, which is
exactly what it did until 2026-08-05. **If you add another consumer of the Postgres
DSN, route it through that function** instead of re-deriving the rule; the bug
survived for weeks because each consumer was individually correct. Design rationale:
[architecture — one DSN, two consumers](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md#one-dsn-two-consumers).

Verify against a real Postgres, not just by re-reading the revision:

```bash
docker run --rm -d -p 55432:5432 -e POSTGRES_PASSWORD=test \
  -e POSTGRES_DB=novafabric_test --name nf-pg postgres:16
NOVAFABRIC_POSTGRES_DSN=postgresql://postgres:test@localhost:55432/novafabric_test \
  uv run alembic -c alembic-postgres.ini upgrade head
NOVAFABRIC_TEST_POSTGRES_DSN=postgresql://postgres:test@localhost:55432/novafabric_test \
  uv run pytest tests/integration/
docker stop nf-pg
```

> Use a **throwaway `docker run --rm`** on a non-standard port as above. Do not use
> `docker compose down` on a shared host — it is project-wide and takes unrelated
> volumes with it.
>
> Run the integration suite against a **fresh** database. The migration is an
> idempotent upsert, so a second run into an already-populated database legitimately
> writes 0 rows — which looks exactly like silent data loss and is not.

---

## Publishing docs to the website

`docs/*.md` is rendered at `novafabric.ai/docs/` by `ui/dashboard/src/lib/docs.ts`, which reads
this directory **directly at build time** via `import.meta.glob`. Nothing is copied,
so the site cannot drift from the docs you edit — but it does mean a docs change is a
site change:

- Adding a file here adds a page and a sitemap entry automatically.
- The page title comes from the first `# heading`; the meta description from the first
  real prose paragraph. A file that opens with a badge row or a blockquote gets a
  worse description — lead with a sentence.
- Relative `.md` links are rewritten to site URLs; links that escape `docs/` go to
  GitHub, because no site route exists for them.
- `docs/releases/` and `docs/whitepaper/` are excluded.

Run `cd ui/dashboard && npm run build` after a structural docs change. It needs **Node ≥ 22.23.4**
(Astro 7.4.2 raised its floor to the October 2026 Node security release).

---

## Where to go next

| If you want to… | Go to |
|---|---|
| Use the CLI (flags, commands, defaults) | [`docs/cli-reference.md`](https://novafabric.ai/docs/cli-reference/) |
| Understand the subsystem map and code structure | [`docs/architecture.md`](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md) |
| Read the capture data-flow internals | [Capture hook mechanism](https://novafabric.ai/docs/concepts/#capture-hook-mechanism) |
| See release sequencing and roadmap | [`ROADMAP.md`](https://github.com/MSKazemi/novafabric/blob/main/ROADMAP.md) |
| Follow the contribution / RFC / commit rules | [`CONTRIBUTING.md`](https://github.com/MSKazemi/novafabric/blob/main/CONTRIBUTING.md) |
| Review a per-decision history | [`docs/decisions.md`](https://novafabric.ai/docs/decisions/) |
| Run the warm capture daemon as a user | [`docs/warm-capture-daemon.md`](https://novafabric.ai/docs/warm-capture-daemon/) |
| Add a schema migration | [Adding a database migration](#adding-a-database-migration) |
| Publish a doc page to the website | [Publishing docs to the website](#publishing-docs-to-the-website) |

Before opening a PR, re-run the four [quality gates](#quality-gates) (pytest ≥ 90%
coverage, ruff, mypy, link check) and, for any CLI change, smoke-test
`uv run nova --help` and the affected sub-command.
