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
- How to set up a local dev environment and run the quality gates (tests, ruff, mypy).
- The repeatable extension patterns for the parts of the codebase you are most likely
to touch: asset types, CLI commands,
nova serveendpoints, report formats, and framework adapters. - How to work on the internal subsystems that back the trust and lineage layers —
the NovaSeal signing code, the maker-checker
promotepackage, the Event Envelope wire format, the standard outer envelopes, and the Security & Provenance Knowledge Graph (SPKG). - The extension points (protocols and entry-point groups) that let you plug in signing backends, watcher backends, eval suites, and lineage backends without forking core.
Maturity note. NovaFabric follows a strict docs-honesty rule: every feature is either works today, experimental, planned, or future design. Many subsystems described here (NovaSeal, the Go collector tier, topology dashboards, object capsule store) are in-tree engineering work toward capabilities that are not part of the released local-first product surface. Sections that document such work are marked experimental or reference the ADR that tracks their design intent. When a section is unmarked, it covers a shipped surface. Never describe an experimental or planned subsystem as a stable product feature in user-facing docs — see the docs-honesty rule below.
Contents
- Local installation
- Quality gates — tests, ruff, mypy
- The PyPI README (build-time link rewrite)
- Common extension patterns
- Adding a new asset type
- Adding a new CLI command
- Adding a new dashboard tab or input
- Adding a new
nova serveAPI endpoint - Adding a new report format
- Extending failure attribution
- Changing what mocked replay serves
- Adding a framework adapter
- Adding a compliance audit profile
- Adding a compliance exporter
- Trust and provenance subsystems
- Working with NovaSeal (experimental)
- Working with the Promote package
- Working with EventEnvelope
- Standard outer envelopes (experimental)
- Working with the SPKG (experimental)
- Standalone trust primitives (Python-API only) (experimental)
- Scale-out and topology work (engineering toward planned architecture)
- Pluggable extension points
- Docs-honesty and maturity labels
- Where to go next
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 importsThe novafabric CLI is then available as:
uv run novafabric --help
# or with the venv activated:
source .venv/bin/activate
novafabric --helpDependency 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:
- Do not add a top-level
import duckdb/import pyarrow/import community/import clickhouse_connectto a core code path.tests/packaging_metadata/test_lean_install_surface.pypins the exact set of modules allowed to need those, and fails on anything new. Prefer a function-level (lazy) import. - 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
ImportErrornaming the exact extra (pip install novafabric[<extra>]). Never a silent wrong answer, never a bareModuleNotFoundError. Seecost/clickhouse_store.py::require_clickhouse_connectfor the house pattern, andtests/packaging_metadata/test_optional_dependency_degradation.pyfor 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_HOMEThis 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
originat the authoritative repo you actually commit to, somake updatefast-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 resolvesmake 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 underpytest-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-indexHow CI runs the suite (ADR-0316)
CI does not run the suite as one job. The unit job is a matrix of six shards, each
running a disjoint set of test files with pytest --shard=I/6:
- Balanced by measured time.
tests/_shard.pyplaces files largest-first onto the lightest shard, weighted bytests/.test-durations.json(per-file seconds from CI's JUnit reports). A file with no recorded time is weighted by its test count, so a stale file costs balance, never correctness. - By file, never by test. Module fixtures and the
xdist_groupthat pins a module's testcontainer to one worker assume the whole module runs in one process tree. - Proven complete. Each shard writes the node ids it ran (
--shard-manifest); thecoveragejob compares their union with a fresh--collect-onlyand fails if any test ran in no shard or in two. It also combines the shards' coverage and enforces the 90% floor there. - Readable when red. The
coveragejob writes a job summary — failures, test seconds per shard, slowest files — and uploads a refreshedtest-durationsartifact. When the shards drift out of balance,gh run download <run-id> -R MSKazemi/novafabric -n test-durationsthenmake test-durations, and committests/.test-durations.json.
The test session also pins its own colour environment (tests/conftest.py): Typer forces a
colour terminal when GITHUB_ACTIONS, FORCE_COLOR or PY_COLORS is set, which used to make
help-text assertions fail in CI only (issue #21). Tests that need colour set it themselves.
Releases are gated on this: every tag-triggered workflow waits for CI on the tagged commit and
publishes nothing unless it passed (docs/release-process.md, step 7).
Tests
uv run pytest --cov=novafabric --cov-report=term-missingRun a focused subset with -k:
uv run pytest -k test_validate -vRuff (lint)
uv run ruff check src tests
uv run ruff check --fix src tests # auto-fix safe issuesmypy (types)
uv run mypy srcmypy is configured with strict = true in pyproject.toml. All type errors must
be resolved before merging.
The PyPI README (build-time link rewrite)
README.md is written for GitHub, where docs/foo.md and docs/assets/demo.svg resolve
against the repository. pypi.org renders the same text with no repository root, so those
links and the demo image would break. pyproject.toml therefore declares
dynamic = ["readme"] and a hatchling metadata hook, scripts/pypi_readme_hook.py,
builds the long description: it rewrites every relative Markdown link and image,
reference definition, and HTML src/href to an absolute URL pinned to the release tag
v{version} (works today).
- Pages:
https://github.com/MSKazemi/novafabric/blob/v{version}/<path>(a trailing/givestree/). - Images and media (
.png .jpg .gif .svg .webp .mp4...):https://raw.githubusercontent.com/MSKazemi/novafabric/v{version}/<path>. - Unchanged: absolute URLs,
mailto:,//host/..., same-page#anchorlinks, and anything inside fenced code blocks. Fragments on rewritten links are kept.
README.md itself is never modified, so GitHub renders it as before. The rewritten text
lands in the wheel's METADATA and the sdist's PKG-INFO (the sdist also carries
scripts/pypi_readme_hook.py and the original README, so a wheel built from the sdist gets the same
text). The links point at a tag, so they resolve once v{version} is pushed; a local
uv build before tagging produces links that 404 until then. The hook is stdlib-only and
offline (no new dependency; hatch-fancy-pypi-readme is MIT but cannot classify
page vs image or skip code fences). Guard:
tests/packaging_metadata/test_pypi_readme_links_are_absolute.py runs the rewrite on the
real README and fails if any relative link remains. To inspect by hand:
uv build --out-dir "$(mktemp -d)" and read METADATA / PKG-INFO (never build into the
repo's dist/).
Adding a new asset type
The Asset Registry supports seven asset types (model, agent, prompt, tool, dataset, evaluation, deployment). To add an eighth:
- Add a new value to
AssetTypeinsrc/novafabric/spec/models.py. - Add
<Type>SpecFieldsand<Type>SpecPydantic models following the existing pattern (seeModelSpec,DatasetSpec, etc.). - Add the new spec class to the
AssetSpecunion discriminator inmodels.py. - Add a test fixture in
tests/fixtures/valid_<type>.yaml(the existing ones arevalid_agent.yaml,valid_model.yaml,valid_prompt.yaml). - Add a test in
tests/test_spec_validator.pyasserting the fixture validates.
Adding a new CLI command
Create
src/novafabric/cli/<command>.pywith a<command>_cmdfunction.Register it in
src/novafabric/cli/main.pywithapp.command(...).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. SeeCapsuleDirectoryError(capture/orchestrator.py, defect B11) and_ALEMBIC_MISSING_HINT(metadata_store/cli.py, defect B4) for the two shapes. Never let a bareOSError/FileNotFoundErrorreach the user.Escape any literal
[in ahelp=string asr"\[". Typer renders help through Rich, which parses[...]as markup, so a plains3://bucket[/prefix]raisesMarkupErrorand the command answers--helpwith a traceback (defect B5). Seecli/verify.pyandcli/_extras.pyfor the convention.tests/cli/test_every_command_help_renders.pyrenders every command's help and will fail if you forget.Regenerate the dashboard command registry so the CommandsTab stays a complete mirror of the CLI:
uv run python ui/dashboard/scripts/gen-command-registry.pytests/serve/test_command_registry_coverage.pyre-runs this introspection in CI and fails if the checked-ingeneratedCommands.tsdrifts 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 withisinstance(cmd, click.Group). Typer 0.27 vendors its own Click, so that check isFalsefor 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: usenovafabric.serve.introspect, notisinstance(r, APIRoute)overapp.routes, which FastAPI 0.141 made empty for every included router.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> --helpAdding 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 bundlecopy-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=lineOn a machine already running
nova serveon the Playwright port, the config'sreuseExistingServerwill silently test that server's (possibly stale) bundle instead of your working tree. PassPW_PORT=<free port>to get a clean one. CI setsreuseExistingServer: falseand 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:
- Import
SuggestInputfrom../../ui/SuggestInput(adjust path). - Load the suggestion pool from the server (if not already in scope) —
api.listAssets(),api.listRuns(), orapi.listHolds(). - Map to
string[]: e.g.assets.map(a => \${a.name}@${a.version}`)`. - Replace the bare
<input type="text">with<SuggestInput value={...} onChange={...} suggestions={pool} className={...} />. - 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:
- Resolve the capsule using
_resolve_capsule(run_id, capsule_dir)— raises HTTP 404 if the directory doesn't exist. - Require auth with
dependencies=[Depends(verify_token)]on the route decorator. - 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 toread.tests/serve/test_authz_route_table.pyfails the moment you add a route without a line, so you will hit this in CI rather than in production. - 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:
- no glob / wildcard /
LIKE— the operators are exactly= != < <= > >=plusIN; - no metric predicates —
--wherefilters the eightDIMENSIONSonly. A metric is something the DSL aggregates.nova query --where 'cost > 0.5'raises.
Changing what the query indexer extracts
src/novafabric/query/indexer.py defines CallRow/ScoreRow, and
src/novafabric/query/cache.py derives its stored columns from those dataclasses
(_CALL_FIELDS). So adding a field is automatic for serialization — and that is exactly why the
version matters:
⚠ If the indexer learns to read something new, bump INDEXER_SCHEMA_VERSION in the same
change. A row cached under the old version has fewer values and rehydrates with the new fields
silently defaulted to None. When ADR-0233 added parent_run_id, an un-bumped cache would have
made every capsule look like a root, and a --scope root|tree query would have answered
confidently and wrongly from a warm cache — the wrong-but-fast outcome ADR-0225 D3 forbids.
⚠ The converse matters just as much: do not bump for a feature that extracts nothing new.
ADR-0236's ratio() is arithmetic over aggregates the indexer already produced; bumping for it
would discard every user's query cache for no reason. The constant's own docstring states the
test — "whenever the indexer changes what it extracts" — and that, not an ADR's
instruction, is what decides it.
Any new file the indexer opens must also be added to INDEXED_FILENAMES, which is what the
cache's change signature is derived from; a file read but not listed there is a stale-answer
defect, and tests/query/test_indexer_signature_coupling.py guards it.
Adding an aggregate
Any surface that sums, counts, averages, or percentiles must return an ADR-0234 verdict from
src/novafabric/serve/aggregates.py rather than a bare number:
from novafabric.serve.aggregates import AggregateCondition, computable, refuse
if store_is_unreachable:
return refuse(
AggregateCondition.SOURCE_UNAVAILABLE,
reason="the cost store did not answer, so no figure can be established",
remedy="check NOVA_CLICKHOUSE_URL; the capsule cost facet works offline",
)
return computable(total, unpriced_calls=unpriced)⚠ A remedy is required and is checked at construction — refuse(..., remedy="") raises.
"Unavailable" is not a remedy. ADR-0234 names a bad refusal message as the entire risk of this
design: users will meet refusals, and a refusal with nothing actionable in it is worse than the
approximation it replaced.
⚠ A refusal carries no value. Not zero, not null. The ADR rejects render-the-approximation-with-a-badge precisely because a number on screen is read as a number regardless of the badge beside it.
⚠ "Absent is not zero" — and the converse. A missing measurement (an unpriced call, a usage
component the provider never reported) must not be summed as 0; the Run Capsule schema states
this as a format invariant. But a count of nothing is legitimately 0, and the or 0
coercions over SQL counts are correct. Do not turn this rule into "zero is suspicious" — a rule
that refuses to report good news gets switched off.
Adding a store to the nova serve read path
Any new backing store must declare its tenancy in STORE_TENANCY
(src/novafabric/serve/tenancy.py) as one of three classes:
| Class | Means |
|---|---|
aware |
it can answer "show me only tenant X's rows" — it carries a real discriminator |
agnostic |
it does not filter by tenant because the deployment supplies the isolation |
unsafe |
it does not filter by tenant and nothing else does either |
agnostic and unsafe both mean "does not filter by tenant" and have opposite safety
consequences. Collapsing them is how the second gets mistaken for the first, which is why there
are three values and not a boolean.
⚠ aware is a claim about the schema, and tests/serve/test_tenancy_registry.py checks it
against the schema — the module you name is read, and an aware store must really contain its
discriminator while an unsafe one must contain none. Declaring aware without a discriminator
fails; so does an unsafe store that has quietly grown a tenant column.
⚠ Mentioning "tenant" is not being tenant-aware. object_capsule_store mentions it fourteen
times and thirteen are per-tenant encryption keys (ADR-0243) — key material, which scopes no
query. Give the evidence field the specific thing that makes the claim true.
An unsafe store must carry a reason an operator can act on: it is what nova serve prints
when it refuses to start in multi-tenant mode, and a refusal with no why is noise.
Touching the Postgres tenant context (FR-07 / FR-13)
The Postgres metadata store isolates tenants with row-level security keyed on the
app.current_tenant_id setting, written only as SET LOCAL inside
begin_tenant_context()'s transaction. A session-scoped write survives the transaction and
leaks one tenant's context to the next user of a pooled connection. Two guards hold the line
(works today):
- Static, default-deny (
tests/metadata_store/test_grep_constraints.py). Every executable occurrence of a tenant-GUC or RLS primitive —app.current_tenant_id,set_config,current_setting,FORCE/ENABLE ROW LEVEL SECURITY, a libpqoptions=-c …,PGOPTIONS— is a finding unless it is one of the reviewed call sites inKNOWN_GOOD_SITES. Matching is case-insensitive and whitespace-tolerant, and sees through concatenation, f-strings and.format; comments and docstrings are ignored. Any such primitive outsidemetadata_store/fails FR-13. Adding a call site — even a correct one — fails until you add it toKNOWN_GOOD_SITESwith a one-line reason; that is the review step, not a false positive. - Behavioural (
tests/metadata_store/test_tenant_guc_behaviour.py). It drives the real store against a recording connection and checks every statement it executes, parameters bound andpsycopg.sqlcomposition rendered: the only accepted tenant-GUC writes areSET LOCAL … = '<uuid>'andset_config(…, true), inside the transaction, with no session options on the connection. A container test pins the checker against a real Postgres.
A static scan cannot be complete — a name assembled at run time from pieces it never sees whole
can evade it. That is what the behavioural guard is for: route every tenant-GUC write through
begin_tenant_context() so the guard exercises it.
Adding a capsule facet
A facet is an additive block under capsule["facets"] recording one kind of
evidence about a run. It is the most-repeated extension pattern in this codebase —
28 modules as of 2026-09-03 — so it has a contract, and
tests/docs/test_capsule_facet_contract.py enforces it across every module rather
than naming them.
The contract
Every facet module defines:
| symbol | why |
|---|---|
FACET_NAME |
the key under capsule["facets"]. Must be unique — two modules claiming one key means one silently overwrites the other, which the guard rejects. |
SCHEMA_VERSION |
a facet without one cannot be read back safely once its shape changes. |
attach_facet(capsule, facet) |
writes it in, additively. |
and registers FACET_NAME in the capsule schema. ⚠ This step is not optional and is easy to miss. properties.facets is additionalProperties: false (ADR-0196 D2), so a facet name the registry does not know makes every capsule carrying it fail
nova validate with "Additional properties are not allowed". Add one entry —
"my_evidence": { "type": "object", "description": "See ADR-0XXX." }— to all three live copies:
src/novafabric/schemas/run-capsule.schema.json— what an installed CLI validates againstschemas/run-capsule.schema.json— the OAS v1.0 target (not yet in force, ADR-0034 §1)ui/dashboard/src/data/schemas/run-capsule.schema.json— the dashboard's copy, kept identical to the packaged one bytests/packaging_metadata/test_site_schemas_match_packaged.py
Use "type": "array" when your facet is a list of records rather than one object.
tests/docs/test_capsule_facet_contract.py fails if any declared FACET_NAME is absent
from any of the three. On 2026-09-03 that check did not exist and 13 of 28 facet names
were unregistered — every one of those modules had a green attach_facet test against a
plain dict, and the schema had its own green validity test, and nothing spanned the two.
Your attach_facet test proves the shape you write; only a test that runs the real
validator proves a capsule carrying it still validates. Write both.
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 outThe four invariants every existing facet honours
- 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.
- Fail-open. Absent material means no facet. Never an exception, never a blocked workload. NovaFabric must not break the thing it is observing.
- 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. exclude_none=Truewhen 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 excAbsent 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.
- Add a new
if format_ == "<name>": return ...branch (or a siblinggenerate_report_<name>()function for a binary format) ingenerator.py. - Add the value to the
ReportFormatenum insrc/novafabric/cli/report.pyand dispatch on it inreport_cmd(mirror thepdfbranch if the format is binary and needs--output). - Add a test in
tests/test_report.pyasserting the output is valid for the format, and a CLI test for the new--formatvalue.
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:
- Add a taxonomy cue. Append keyword cues to the appropriate entry in
_TAXONOMY_CUESinattribution.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). - Add a scoring signal. In the coarse pass of
attribute_failure, add a bounded, additive term toscore. Keep terms small and bounded (no unbounded weights) and keep the function deterministic for a given capsule. - New taxonomy category. Extend the
AgentErrorTaxonomyenum — 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). - Test first. Add a fixture in
tests/test_attribution.py(a multi-step failed run where the responsible step is known) asserting the rank and label.
Do not write attribution results back into the lineage store from this module — it is read-only by design (ADR-0084). Scores are relative ranking weights, never presented as calibrated probabilities.
Related, newer modules in diagnose/ (ADR-0101, experimental):
verify.py::verify_hypothesis/search_root_causeback the CLI'snova diagnose --interveneand--search-root-causeflags — they replay the capsule counterfactually (ADR-0086, mocked/zero-token) and record an evidence-basedCONFIRMED/REFUTED/INCONCLUSIVEverdict rather than a guess.causal_graph.py::causal_root_candidates(NF-019/022) back-traces the span graph to root failure nodes (a failing node with no failing ancestor). It has no CLI verb of its own — it's consumed internally bysearch_root_cause. Every candidate carriesverification="unverified"until an intervention replay confirms it.claim_audit.py::audit_claims(NF-021) marks a model-span claimungroundedwhen no tool-span evidence precedes it on the answer path — a structural hallucination-risk signal, no NLP. This is Python-API only today — it is not wired intonova diagnose's output or any CLI command. If you add a CLI surface for it, follow the "Adding a new CLI command" pattern above and updatedocs/user-guide.md's v0.75–v0.94 cohort table to move it from "Python API only" to "CLI".
Changing what mocked replay serves (ADR-0300, ADR-0304)
Mocked replay serves a recorded response only on an SDK surface it patches. Three places must agree, and a test holds them together:
- Capture (
capture/hooks/_openai.py,_anthropic.py) must record the call with its response in the canonical model-call shape, and mark the record withextensions["io.novafabric.api_surface"]. A streamed call goes throughcapture/hooks/_sdk_streams.py(a transparent proxy that folds the chunks and writes one record when the stream ends). Run the SDK call insiderecord_roles.sdk_call_scope(call_id)and write the SDK record with thatcall_id(stamped bystamp_logical_record), so the wire hook's records underneath becometransportrecords of it (ADR-0305). A new wire hook callsstamp_wire_record. Any code that counts or iterates model calls goes throughrecord_roles.logical_model_calls— neverlen()of the file — and a serving rule must rejectis_transport_record(record)before anything else. - Replay (
replay/_contract.py:model_queue_key+MODEL_SURFACES, andreplay/_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 toUNSUPPORTED_MODEL_SURFACES. - The support matrix — edit the rows in
replay/_support_matrix.py, then runuv run python scripts/gen_replay_support_matrix.py. Never edit the table indocs/architecture/replay-modes.mdby hand:tests/replay/test_support_matrix_is_generated.pyfails 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 manifestid, 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:
- Create
src/novafabric/compliance/export/my_format.pywith the exporter class. - Add a CLI command in
src/novafabric/cli/export_evidence.py(seeexport_ropa_cmdas a template). - Register the CLI command in
src/novafabric/cli/main.py. - Add a
nova serveendpoint insrc/novafabric/serve/app.pyfollowing thecompliance_export_ropa_endpointpattern — import the exporter inside the function to avoid unconditional heavy imports. - Add an
api.tsmethod inui/dashboard/src/lib/api.ts(seeexportRopaas a template). - Add a
<MyFormatExportPanel>component as its own file underui/dashboard/src/components/dashboard/tabs/compliance/(built onPanelScaffold), then register it in the manifest attabs/compliance/index.tswith itsgroup— the hub renders whatever the manifest declares. (Before v0.97.0 these panels lived inline inComplianceTab.tsx; that file is now just the sub-navigation shell.) - Write integration tests in
tests/test_serve_compliance.py— one happy-path test and one 422 on missingrun_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 onlysha256sumplus 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 -vRunning the seal latency gate (enforced in CI, ~1 s locally):
uv run pytest tests/seal/test_benchmark.py -v
# or:
make benchmarkThe 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):
- Add the new
profile:value to_SUPPORTED_PROFILESinconfig.py(an unknown value is refused there), and its fields to theSigningProfiledataclass. - In
config.py._parse_profile, add validation for the new profile's required fields. - In
config.py.build_signing_backend, dispatch onprofile.profileand return aSigningBackendfor it.create_envelopeitself needs no change: it signs through whatever backend it is handed, and capture finalization (capture/finalize.py) builds one withbuild_signing_backendfor every non-localprofile. - Add fixtures in
tests/seal/for the new profile. - 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 auditNovaSeal.__init__ calls open_merkle_log(db_path) which dispatches by URI prefix:
Pathor non-DSN string →MerkleLog(SQLite, default)postgresql://orpostgres://prefix →PostgresMerkleLog(psycopg3)
Tables are self-bootstrapped on first connection (CREATE TABLE IF NOT EXISTS).
No Alembic migration is needed. The one schema change so far is additive and applied by
the log itself on open (ADR-0313): a nullable root_version column on the tree-head
table (NULL = v1) and a log_meta / nova_seal_log_meta table holding the log's root
version. A log that already has leaves and no recorded version is recorded as v1.
Tests for the Postgres backend are in tests/seal/test_postgres_merkle.py, gated
behind NOVA_INTEGRATION=1 + NOVA_TEST_POSTGRES_DSN.
Seal bundle location convention:
All sealing code writes to <capsule_dir>/.seal/:
manifest.dsse— DSSE envelope bytes (UTF-8 JSON)manifest.dsse.tsr— RFC 3161 TSR bytes (DER binary; empty if TSA skipped)log-entry.json— Merkle log entry (JSON, human-readable)
Working with the Promote package (promote/)
The maker-checker linked-envelope chain lives in src/novafabric/promote/.
Tests are in tests/promote/. Test key fixtures live in tests/fixtures/promote/keys/
(ECDSA P-256, test-only). They are not committed — the blanket *.pem gitignore rule keeps
them out — and are generated by tests/fixtures/promote/generate_keys.py. The test session
runs it on demand when they are absent (tests/conftest.py); to use the keys outside pytest,
for the smoke test below, run uv run python tests/fixtures/promote/generate_keys.py once.
Running promote tests only:
uv run pytest tests/promote/ -v --tb=shortModule 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.
- Add the JSON Schema to
src/novafabric/schemas/promote_<name>_v1.json;validate_predicate("promote_<name>_v1.json", predicate)loads it by filename. - Add a
<NAME>_PAYLOAD_TYPEconstant topredicates.py. - Add
build_<name>_predicate()topredicates.py. - Add the CLI command in
src/novafabric/cli/seal_propose.pyunderseal_app. - 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 passedWorking 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 -vAdding a new event_type:
- Add the new string value to
EventTypeinenvelope/models.py. - Add the same string to the
"enum"array inschemas/event-envelope-v1/envelope-v1.json. - 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()) " - Update
envelope-v1.protowith the same new string in a comment next toevent_type. - Add tests in
tests/test_event_envelope.pyfor the new event_type.
Changing the envelope schema (adding an optional field):
Adding an optional field is backwards-compatible (additive). Steps:
- Add the field to
schemas/event-envelope-v1/envelope-v1.jsonwith"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). - Add the field to
envelope-v1.protowith the next available tag number (alphabetical order). - Add a Pydantic field to
EventEnvelopeinenvelope/models.pywith a default ofNone. - Recompute the sha256 pin (see above).
- 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 EventEnvelopeto_cloudevents() always emits the four required CloudEvents context attributes
(id, source, type, specversion="1.0"). The mapping is additive — the
internal EventEnvelope model is untouched and the schema pin does not change.
Key contract (mirrors collector/pkg/envelope/cloudevents_mapping.go so
Python↔Go interop holds over the same topic):
| EventEnvelope field | CloudEvents attribute |
|---|---|
event_id |
id |
agent_id |
source (nova://agent/{agent_id}) |
event_type |
type |
started_at |
time |
trace_id + span_id |
traceparent extension (00-{trace}-{span}-01) |
global_run_id / run_id |
novarunglobal / novarun extensions |
parent_run_id / cluster_id / tenant_id / emitter_node_id / payload_hash |
novaparentrun / novacluster / novatenant / novaemitter / novapayloadhash (omitted when null) |
nova.batch.signature / nova.batch.signing_key_id |
novabatchsig / novabatchkid |
payload |
data |
Extension attribute names are lowercase [a-z0-9] per the CloudEvents naming
rule (no hyphens or dots). Unknown broker-injected extension attributes are
preserved across a from_cloudevents() → to_cloudevents() round-trip (stashed
under the model's cloudevents_extensions extra block). Tests:
tests/test_envelope_cloudevents.py.
Out of scope (Wave 2): SCITT COSE Receipts — the other half of gap-009 — are not yet implemented.
Standard outer envelopes (envelopes/) — experimental
The src/novafabric/envelopes/ package (NF-029/030/031, ADR-0096, experimental) wraps
NovaFabric's inner artifacts in stock, third-party-verifiable envelopes — wrap, don't replace:
the inner bytes become the envelope payload verbatim and are never rewritten. Tests live in
tests/envelopes/.
| Module | Emitter | Envelope |
|---|---|---|
envelopes/dsse.py |
wrap_bundle(bundle_bytes, signer) |
DSSE, payloadType application/vnd.novafabric.bundle+json |
envelopes/intoto.py |
capsule_statement(capsule_dir, …) |
in-toto Statement v1, predicateType novafabric.dev/capsule/v1 (per-file sha256 subjects) |
envelopes/slsa.py |
promotion_provenance(…) |
in-toto Statement, predicateType https://slsa.dev/provenance/v1 |
Single DSSE writer (do not fork). All three emitters sign through the one PAE + signer
implementation in evidence/intoto.py — dsse_sign_payload(payload, payload_type, signer) (and its
statement-level wrapper dsse_sign(statement, signer)). A signer is any object exposing
sign(bytes) -> bytes and keyid: str (e.g. evidence/signing.py::LocalSigner, or the keyring-key
adapter in cli/promote.py). Never add a second DSSE code path (requirement 2 of ADR-0096).
Digest fidelity. capsule_statement(..., expected_digests=...) raises SubjectDigestMismatch if a
recomputed per-file sha256 disagrees with an expected one — the emitter refuses to produce a
verifying-but-wrong attestation.
Schema conformance. envelopes/_schemas/ vendors the in-toto Statement v1 and SLSA Provenance v1
required-field contracts; envelopes/schema.py::validate_intoto_statement() /
validate_slsa_provenance() assert emitter output against them (raising EnvelopeSchemaError), so a
renamed/missing field fails fast instead of producing an envelope a stock verifier would reject. These
JSON files are shipped as wheel package data (pyproject.toml [tool.hatch.build.targets.wheel].include) —
add any new vendored schema to that list.
CLI surfaces (all opt-in; output byte-for-byte unchanged when the flag is absent):
nova export-evidence --dsse— DSSE-wraps the final bundlemanifest.json→<bundle>.dsse.json.nova promote direct --slsa-provenance— emits a DSSE-signed SLSA provenance →<name>-<version>.slsa.json.nova verify-envelope <env.json> --key <pem>— verifies any of the above with an Ed25519 key.
Three distinct DSSE verify paths now coexist — do not cross them:
trust/novaseal/envelope.py::verify_envelope()— a capsule's NovaSeal seal (application/vnd.novafabric.capsule+json; also checks RFC 3161 + Merkle). Used bynova verify.promote/predicates.py::verify_promote_envelope()— maker-checker promote bundles (…promote.proposal/approval/policy+json).cli/verify_envelope.py(nova verify-envelope) — the outer envelopes above (…bundle+json, in-toto, SLSA), verifying the signature only viaevidence/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, andnova verifyflags 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
- localhost host guard as the other
/api/kg/*routes:
POST /api/kg/detect— mirrorsnova kg detect(body{capsule_path, top}→{ok, count, findings}); no extra.POST /api/kg/attack-path— mirrorsnova kg attack-path(body{capsule_path, from_entity, to_entity, max_depth}→{ok, path_found, hops}); needs[spkg].POST /api/kg/blast-radius— mirrorsnova kg blast-radius(body{capsule_path, entity, upstream, max_depth}→{ok, direction, count, entities}); needs[spkg].
All resolve a bare run_id to its capsule dir. The dashboard React panels that consume these are a
follow-up built with the packages/nova-dashboard toolchain (not part of this Python slice).
Two hard invariants — do not break these:
- 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.jsoncontract and the RDFnf:FindingShapeSHACL constraint (sh:orovernf:mapsToTechnique/nf:mapsToCountermeasure). When you add a detector, emit findings viato_findings()/finding_to_rdf(); never invent a second finding shape. - R11 — SHACL gate before write.
build_spkgvalidates the canonical PROV-O layer before touching the operational store; on failure it raisesSpkgValidationErrorand 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 persistenceBuild 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-testKey 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-walnovafabric-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. Thenovaseal_batch_signerprocessor builds its signer from its config:local_wal: trueuses the dev-key keystore;keystore_endpointis refused (the KMS keystore needs an mTLS client certificate the config has no fields for yet — planned); with neither set it refuses to start unlessfail_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:
- Create
collector/pkg/<name>/orcollector/internal/<name>/. - Write tests in
_test.gofiles in the same package. - Run
GOPATH=~/gopath go test -race ./...before committing. - Ensure all dependencies are Apache-2.0, MIT, or BSD-3 (per ADR-0024).
- If the package introduces a new
nova_*Prometheus metric, register it incollector/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 thingRules:
- Fail-open: never raise inside the wrapper; log and swallow.
- Use
get_current_recorder()— never instantiate a new recorder. - Restore the original method on
unwrap()if reversibility matters. - Add an entry to
src/novafabric/adapters/__init__.py. - Add the framework to the adapter roster in
docs/for-platform-teams.mdand note it indocs/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 thingbegin_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-resolutiontunes the clustering). Like the rest ofnova 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 -vTo start the server in topology mode:
make serve-topology # build SPA + start nova serve --experimental --topologyTypeScript 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/ -vThe three TV-5 modules are:
snapshot_store_3d.py—SnapshotStore3D: atomic fine/coarse snapshot tiers (msgpack or JSON)layout_pipeline_3d.py—LayoutPipeline3D:networkx.spring_layout3D layout inProcessPoolExecutorrouter_tv5.py— FastAPI router factorymake_tv5_router():/api/tv5/REST + WebSocket
To start the server with TV-5 enabled:
nova serve --experimental --tv5
# Or with the 2D topology dashboard too:
nova serve --experimental --topology --tv5Environment variables:
TV5_SNAPSHOT_DIR— storage path (default.nova/tv5_snapshots)TV5_MAX_FINE_SNAPSHOTS— retention count for fine tier (default 288)TV5_MAX_COARSE_SNAPSHOTS— retention count for coarse tier (default 168)
The optional msgpack package enables binary snapshot transport. Without it, JSON is used automatically.
TypeScript SPA (TV-5)
TV-5 adds one new component to the nova-dashboard SPA:
src/components/topology/TV5Panel.tsx— Three.jsInstancedMeshper node type,LineSegmentsedges,OrbitControls, time-slider, WS live feed, p99 health color encoding
The existing src/App.tsx has a tab switcher: 2D View (Sigma.js, existing) / 3D View (TV-5) (Three.js, new).
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 TV5PanelWarm 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:
- The prefork model depends on
import novafabricstarting no background threads. A fork after a thread is started corrupts the child. There is a gate test for this —tests/daemon/test_fork_safety_preflight.py. If you add an import-time thread anywhere in the import graph, that test fails and the daemon is unsafe; make the thread lazy instead. client.pymust stay stdlib-only. Importing it must not pull in thenovafabricpackage, or the thin client re-acquires the cold-start the daemon exists to remove. It carries its own small copies of the frame helpers. There is no test that mechanically enforces this — keep its imports to the standard library by hand.run_workerruns only in a forked child. It rebinds stdio, callssetpgrp, and maykillpgits own process group — never call it in-process. It is exercised for real (via an explicitos.fork) intests/daemon/test_worker_integration.pyand end-to-end intests/daemon/test_fidelity_e2e.py; fork/loop-only branches are marked# pragma: no coverwith a pointer to those tests.- Fidelity is the contract. The worker must run the existing
CaptureOrchestratorunchanged so a daemon capsule stays structurally identical to a direct one.tests/daemon/test_fidelity_e2e.pyenforces this — keep it green.
To extend the protocol (e.g. a new op), add it in server._handle (the parent
reads the first frame and decides whether to fork) and mirror any client-side
framing in client.py. Liveness probes are a bare connect that the parent reads as
None and closes — they must never fork.
Extension points
These are the plug-in surfaces that let you integrate NovaFabric with your own
infrastructure without forking core. Each is a typing.Protocol or an entry-point
group with a stable contract.
BypassNotifier — bypass event dispatch
Implement the BypassNotifier Protocol (src/novafabric/promote/bypass_notify.py) to
route maker-checker bypass events to any destination:
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:
pip install novafabric[seal-aws]— AWS KMS (boto3)pip install novafabric[seal-azure]— Azure Key Vaultpip install novafabric[seal-gcp]— GCP Cloud KMS
ZstdDictRegistry — OCS compression dictionaries
Use ZstdDictRegistry (src/novafabric/object_capsule_store/zstd_dict.py) to train
and register named zstd dictionaries for capsule compression:
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 CapsuleReceiptObjectCapsuleStore(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 hopsJanusGraphLineageStore(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 containerBoth 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
postgresservice (port 5432) — the AGE extension is not present in plainpostgres: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 andprod.
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:
- Add
CREATE NODE TABLE IF NOT EXISTS <Type> (id STRING PRIMARY KEY, …)to the module-levelSCHEMA_DDLlist inkg/store.py(KGStore.init_schema()runs it). - Add a
merge_<type>()method onKGStoreand the corresponding upsert logic. - Register the type in
EntityNormaliser.normalise()dispatch (kg/entity_normaliser.py). - Add extraction logic in
KGIngestionPipeline.ingest_event()(kg/pipeline.py), resolving each raw name through_resolve_entity(). - Include the new type in
get_node_counts()/get_topology_graph()inkg/store.py. - Add the type to
KGTopologyinui/dashboard/src/lib/api.tsand updateTopologyLayerPanelinui/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_servernova 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 UNCHANGEDRegister 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):
- Built-ins first, always. Your masker observes already-redacted markers; it can never un-redact them.
- Same files as the built-in scanner. Your masker is offered every string value
of the call and event streams,
env.lock,assets.jsonl,lineage.jsonland the manifest (beforecapsule.yamlis written), and every line of each text file underinputs/andoutputs/(target_refoutputs/stdout.txt#L3). Binary files and files over the 64 MiB scan limit are not offered to maskers; the built-in scanner still covers them, streaming a file that large in 8 MiB windows that overlap by 64 KiB, so it is never held in memory. A text file is redacted in place exactly as a small one would be; a binary that carries a secret is dropped. Residual: a single match longer than 64 KiB, or a cut forced inside a whitespace-free run longer than 32 MiB, may differ at that one point. Releases up to v0.107.0 skipped such files entirely and sealed them unscanned. - Built-ins last, too. After every masker has run, a final built-in pass
rescans the finished capsule. A masker whose output looks like a key cannot leak
it: the pass redacts it and records it in
residual_check. - Fail-closed on secrets. If your masker raises, times out, exceeds the input cap,
returns a non-string, or returns output still containing the raw value, the field is
redacted (or dropped, per
on_error) and the failure is recorded in the proof'smasker_errors[]. The raw value is never written. - Fail-safe for the workload. A masker failure never crashes or blocks the captured command; capture continues. A masker that cannot be loaded, however, aborts capture before the workload starts — expected masking is never silently skipped.
- Every mask is evidence. Each applied mask emits a
masker_findings[]entry inredaction-proof.json(masker_id,pattern_id,target_ref,match_hashof the pre-mask bytes,chain_position); the proof stays hash-chained.match_hashis an HMAC-SHA256 under a key generated for each capture and never stored: findings that masked the same value within one capsule share it, but it cannot be matched against a candidate afterwards or linked to another capsule. A masked value is often the PII itself (user.email), and a plain SHA-256 of it, sealed into a WORM capsule, could be reversed by dictionary and would outlivenova pii erase. Releases up to v0.107.0 stored that plain digest.
Reference implementation: novafabric.masking.examples.EmailMasker (registered as
novafabric-email). See ADR-0135 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:
- 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.
- 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, PostgresA 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 headSQLAlchemy 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-pgUse a throwaway
docker run --rmon a non-standard port as above. Do not usedocker compose downon a shared host — it is project-wide and takes unrelated volumes with it.Run the integration suite against a fresh database. The migration is an idempotent upsert, so a second run into an already-populated database legitimately writes 0 rows — which looks exactly like silent data loss and is not.
Publishing docs to the website
docs/*.md is rendered at novafabric.ai/docs/ by ui/dashboard/src/lib/docs.ts, which reads
this directory directly at build time via import.meta.glob. Nothing is copied,
so the site cannot drift from the docs you edit — but it does mean a docs change is a
site change:
- Adding a file here adds a page and a sitemap entry automatically.
- The page title comes from the first
# heading; the meta description from the first real prose paragraph. A file that opens with a badge row or a blockquote gets a worse description — lead with a sentence. - Relative
.mdlinks are rewritten to site URLs; links that escapedocs/go to GitHub, because no site route exists for them. docs/releases/anddocs/whitepaper/are excluded.
Run cd ui/dashboard && npm run build after a structural docs change. It needs Node ≥ 22.23.4
(Astro 7.4.2 raised its floor to the October 2026 Node security release).
Where to go next
| If you want to… | Go to |
|---|---|
| Use the CLI (flags, commands, defaults) | docs/cli-reference.md |
| 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.