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 instead.

What you will learn

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 below.

Contents


Local installation

Requires Python 3.12+ and uv.

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:

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:

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).

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 niced, 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 niced.

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):

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:

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

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

Run a focused subset with -k:

uv run pytest -k test_validate -v

Ruff (lint)

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

mypy (types)

uv run mypy src

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


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).

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:

    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:

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:

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:

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.
@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": ...}
# 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:

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:

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):

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 —

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

— to all three live copies:

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.

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:

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):

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:

# 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:

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:

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

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

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:

pip install novafabric[seal-postgres]

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

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:

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/:


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:

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:

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.

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:

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:
    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.

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:

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):

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) 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

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.

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:

# 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:

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:

# 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):

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):

# 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 and note it in 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:

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)
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). 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:

uv run pytest tests/test_topology_*.py -v

To start the server in topology mode:

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

TypeScript SPA (packages/nova-dashboard/)

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:

uv run pytest tests/tv5/ -v

The three TV-5 modules are:

To start the server with TV-5 enabled:

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

Environment variables:

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:

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

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; 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:

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:

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). 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:

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:

ZstdDictRegistry — OCS compression dictionaries

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

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:

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):

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:

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:

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:

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):

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:

# 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:

[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.

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 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:

# 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:

[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):

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):

Reference implementation: novafabric.masking.examples.EmailMasker (registered as novafabric-email). See ADR-0135 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 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 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.

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:

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. 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://:

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.

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

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:

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
Understand the subsystem map and code structure docs/architecture.md
Read the capture data-flow internals Capture hook mechanism
See release sequencing and roadmap ROADMAP.md
Follow the contribution / RFC / commit rules CONTRIBUTING.md
Review a per-decision history docs/decisions.md
Run the warm capture daemon as a user docs/warm-capture-daemon.md
Add a schema migration Adding a database migration
Publish a doc page to the website Publishing docs to the website

Before opening a PR, re-run the four 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.