NovaFabric Operator Guide
This guide is for HPC system administrators and platform engineers who deploy NovaFabric on shared infrastructure — from a single workstation to a multi-node SLURM cluster or a Kubernetes namespace. NovaFabric captures, replays, diffs, and audits AI-agent and model runs, turning any command into a portable, schema-valid, secret-scanned Run Capsule you own. This document covers the operational mechanics: installation, runner configuration, wire-level capture, site-specific URL-registry customization, cryptographic sealing, and a full Docker Compose deployment.
What you will learn
- The capture mechanism — how a single
sitecustomize.pyhook loader makes every runner work the same way, with no application code changes (§1). - Prerequisites — Python, package install, and what compute nodes need (§2).
- Four deployment scenarios, in increasing order of operational constraint: Local (§3.1), Docker/OCI (§3.2), SLURM (§3.3), and Kubernetes (§3.4).
- URL-registry configuration — how to capture private inference servers (Ollama, vLLM, TGI) and site-wide provider mappings (§4).
- Troubleshooting — the failure modes actually seen in live-cluster validation, with concrete diagnostics (§5).
- NovaSeal — opt-in ECDSA P-256 signing, RFC 3161 timestamps, and Merkle-log inclusion proofs (§5b).
- Docker Compose — the full
nova serve+ Postgres stack, including the data-path variables that silently produce empty dashboard tabs if set wrong (§7).
The mental model
Every runner does exactly one job: guarantee that the capsule directory is
writable, that sitecustomize.py is reachable on PYTHONPATH, and that all
capsule artifacts are on the local filesystem before nova capture returns.
Everything else — Local subprocess, Docker container, SLURM batch job, Kubernetes
Job — is a variation on that theme. The capsule is the source of truth; nothing
leaves the node unless you explicitly push it.
For developer onboarding (contributing code, running tests), see CONTRIBUTING.md. For the complete CLI usage reference (every command, flag, and default), see docs/cli-reference.md. For the full NovaSeal configuration reference, see docs/novaseal-configuration.md.
Table of contents
- Deployment model overview
- Prerequisites
- Deployment scenarios
- URL registry configuration
- Troubleshooting
1. Deployment model overview
NovaFabric is self-contained: there is no server, no agent daemon, and no persistent network service required to capture a run. Capture happens in-process inside the workload's own Python interpreter.
The mechanism is a sitecustomize.py hook loader. When nova capture launches a
workload, it materializes this file to a directory that is prepended to
PYTHONPATH. Python's import machinery auto-loads sitecustomize at interpreter
startup, before any user code runs. The hooks patch httpx, requests,
aiohttp, and urllib3 transports to intercept outbound HTTP calls and write
structured records to a capsule directory.
Every runner — local subprocess, Docker container, SLURM batch job, Kubernetes Job — uses this same mechanism. The runner's only job is to ensure:
NOVAFABRIC_CAPSULE_DIRpoints at a writable directory.- That directory (or another directory on
PYTHONPATH) containssitecustomize.py. - All capsule artifacts from the workload are present on the local filesystem
before
nova capturereturns.
The capsule directory holds the complete record of a run: capsule.yaml (metadata),
model-calls.jsonl (wire-level AI traffic), and outputs/ (stdout, stderr). No
data leaves the node unless you explicitly push a capsule somewhere.
2. Prerequisites
Python version
Python 3.12 or later is required on every node that runs nova capture or any
NovaFabric CLI command. This applies to both the submit node and compute nodes
on SLURM clusters.
Package installation
Install with pip or uv. Both work. uv is recommended for reproducible
environments:
# pip
pip install novafabric
# uv (recommended for shared or HPC venvs)
uv pip install novafabricThe optional serve extra adds the experimental local dashboard (see section 6):
pip install "novafabric[serve]"A few other narrow extras exist for optional integrations: clickhouse
(ClickHouse sink for cost attribution), nats (NATS JetStream lineage
consumer), avro (Avro capsule-event serialization), query (DuckDB
accelerator for nova query), and energy-gpu (NVML GPU energy readings).
Install everything at once with pip install "novafabric[all]" — this excludes cloud-vendor extras (worm-*,
seal-aws/seal-azure/seal-gcp) and agent-framework adapter extras, which
you should install individually for the one vendor/framework you actually use.
To see the full list against your own install rather than this prose, run
nova doctor --check-extras. It reports every declared extra as complete or
incomplete, names the distributions missing from each, and prints the exact
pip install 'novafabric[<extra>]' command. The list comes from the installed
distribution metadata, so it stays correct as extras are added or renamed — this
section can go stale, that output cannot. Exit code stays 0: an omitted extra is
a normal choice, not a failure.
Changed in v0.99.0 — a leaner default install
A plain pip install novafabric now pulls 113 MB / 42 packages instead of
412 MB / 50. Four dependencies moved out of the default install into the extras
that actually import them (ADR-0222), because every one of their import sites
was already behind an extra:
| Moved out of core | Now comes from |
|---|---|
duckdb |
scale, serve, query |
pyarrow |
scale, serve, lineage-migration |
python-louvain |
serve |
clickhouse-connect |
scale, clickhouse |
numpy also disappears from a default install — it only ever arrived
transitively via python-louvain.
Migration. If you relied on
duckdb,pyarrow,clickhouse_connectorcommunitybeing importable after a plainpip install novafabric, usepip install 'novafabric[all]'to restore the previous importability, or install the narrower extra from the table above. Prefer the narrow extra:[all]is a superset of the old default install, not an equivalent of it — it also pullscompliance,spkg,scale,sigstore,janusgraphand the rest, so it ends up considerably larger than the 412 MB you had before. These were never part of NovaFabric's public API, but they did work before.
No CLI flag, capsule schema, evidence-bundle format or REST endpoint changed,
and networkx deliberately stays in core (the CLI imports it at start-up, and
nova insights depends on it). Every default command behaves identically on a
lean install. Where a moved dependency is still reachable, the behaviour is
explicit rather than silent: nova query falls back to stdlib sqlite3 with
identical results and reports index.engine: "sqlite"; nova backup skips the
derived DuckDB topology cache with a stated reason and still succeeds; nova restore fails loudly rather than reporting an unopened .duckdb store as
verified; ClickHouse paths raise an ImportError naming
pip install novafabric[clickhouse].
Container and server deployments are unaffected — deploy/docker/Dockerfile
installs [server,serve,clickhouse,lineage-kuzu,lineage-migration], which
restores the full surface inside the image.
What compute nodes need
For any runner that executes on a remote node (SLURM, Kubernetes):
- Python 3.12+ in the same virtual environment (or an identically installed venv at the same path) that the submit node uses.
- NovaFabric installed in that venv (
import novafabricmust succeed on the compute node). - Network access to the inference endpoints you want to capture (or none, if the workload is expected to fail the connection — capture still records the attempt).
NovaFabric does not require internet access for capture, validation, replay, diff, or local lineage queries. The only outbound traffic is the workload's own AI API calls, and only if the workload makes them.
Scheduler tools
| Runner | Required binaries |
|---|---|
local |
none beyond Python |
docker |
docker (daemon must be reachable) |
slurm |
sbatch, scontrol, sacct on PATH |
kubernetes |
kubectl configured with a reachable cluster |
3. Deployment scenarios
All four runners share the same capture mechanism (§1) and the same URL registry (§4). They differ only in where the workload executes and how capsule artifacts return to the submit node. Pick the runner that matches where your workload runs:
| Runner | --runner |
Where the workload runs | Key operational constraint |
|---|---|---|---|
| Local | local (default) |
Submit node, as a subprocess | None — self-contained on disk |
| Docker | docker |
A container on the submit node | Image must have NovaFabric installed |
| SLURM | slurm |
A batch job on an allocated compute node | Capsule dir must be on shared filesystem |
| Kubernetes | kubernetes |
A Kubernetes Job |
Namespace RBAC; image must have NovaFabric |
Runner defaults can be set per project in .novafabric/runners.yaml and overridden
per invocation with --runner-option key=value. The subsections below document each
runner in increasing order of operational constraint.
3.1 Laptop or single workstation (LocalRunner)
Works today. This is the default runner and requires no configuration beyond installing the package.
nova capture python my_agent.pynova capture launches my_agent.py as a subprocess, injects
sitecustomize.py into a temporary directory on PYTHONPATH, and writes the
capsule to $NOVAFABRIC_HOME/capsules/<run-id>/ (override with NOVAFABRIC_CAPSULE_DIR).
To validate and inspect a run:
# Validate the capsule against its schema
nova validate ~/.novafabric/capsules/<run-id>/
# Inspect the manifest directly (capsule.yaml is human-readable)
cat ~/.novafabric/capsules/<run-id>/capsule.yamlnova validate accepts a spec file, a capsule directory, or a replay directory.
Because the capsule is a plain directory on disk, every other artifact
(model-calls.jsonl, trace.jsonl, outputs/) is directly readable without any
NovaFabric runtime.
No shared filesystem, no scheduler, no network service needed. The capsule is self-contained on disk.
Explicit runner flag (same as default):
nova capture --runner local python my_agent.pyTimeout (default 600 s):
nova capture --runner local --timeout 120 python my_agent.py3.2 Docker or OCI container (DockerRunner)
Works today. The workload runs inside a Docker container. The capsule directory is mounted as a volume so the orchestrator can read artifacts after the container exits.
Requirements:
dockerbinary onPATHon the submit node.- Docker daemon reachable (check with
docker info). - A container image that has NovaFabric installed. The runner does not pick a default image. Build your own:
FROM python:3.12-slim
RUN pip install novafabric
COPY my_agent.py /app/my_agent.py
WORKDIR /appBasic invocation:
nova capture \
--runner docker \
--runner-option image=myorg/agent-runtime:abc1234 \
python my_agent.pyAll runner_options for DockerRunner:
| Key | Required | Description |
|---|---|---|
image |
yes | Fully-qualified image reference |
network |
no | --network value (default: Docker default / bridge) |
workdir |
no | Container working directory |
user |
no | --user value (e.g. 1000:1000); recommended to avoid root-owned capsule files |
extra_volumes |
no | List of host:container[:opts] mount strings |
extra_env |
no | Dict of additional env vars for the container. Passed as docker run -e NAME, never -e NAME=VALUE — see below |
Project-level defaults in .novafabric/runners.yaml:
schema_version: "0.1.0"
default_runner: docker
runners:
docker:
image: myorg/agent-runtime:latest
user: "1000:1000"
network: bridgeSecurity posture: The DockerRunner never passes --privileged, does not
mount the Docker socket, and does not enable host namespaces (hostNetwork,
hostPID, hostIPC). These are hard restrictions in the runner code.
The runner only forwards NOVAFABRIC_* environment variables and any keys
specified in extra_env into the container. It does not pass through the
full host environment, which would risk leaking credentials into the container
at runtime.
Values never go on docker run's command line (works today). Each variable
is passed as -e NAME, and its value is placed in the environment of the
docker client process, from which Docker copies it into the container. A
process's argv is readable by every user on the host (ps,
/proc/<pid>/cmdline) for as long as the container runs; its environment is
readable only by the same user and root. Before this change the runner ran
docker run -e OPENAI_API_KEY=sk-…, so an extra_env API key was visible to
every local user. The same applies to eval containers (run_eval_container).
What is still visible:
docker inspect <container>shows the container's environment, values included, to anyone who can reach the Docker daemon (on most hosts that is root-equivalent already).--runner-option extra_env=K=Von thenova capturecommand line is itself innova capture's argv; put the value in.novafabric/runners.yaml(mode- instead.
- A variable the
dockerclient itself reads —PATH,HOME,USER,TMPDIR,XDG_RUNTIME_DIR,XDG_CONFIG_HOME,SSH_AUTH_SOCK, the proxy variables, and anything starting withDOCKER_,BUILDKIT_orLD_— cannot be placed in the client's environment without changing which daemon, binary or configuration the client uses, so such a name is still passed as-e NAME=VALUE. Never put a credential in one of these names.
3.3 SLURM cluster (SlurmRunner)
Works today. This section is the most detailed because SLURM introduces constraints that do not exist for local or Docker runners.
Overview
The SlurmRunner submits a generated batch script on sbatch's stdin (never
--wrap; see How the job is submitted), polls for completion
using scontrol show job (primary) with sacct as a fallback, and reads
stdout/stderr from per-job output files. Capsule artifacts accumulate on a
shared filesystem, then the orchestrator reads them on the submit node after
the job finishes.
Three failure modes were caught and fixed during live-cluster validation (v0.6.10, v0.6.11, v0.6.12). This section reflects that validated behavior.
Shared filesystem requirement
This is the most important operational constraint for SLURM deployments:
The capsule directory must be on a filesystem shared between the submit node and every compute node SLURM might allocate.
If the capsule directory is on node-local storage (e.g. /tmp or any path
that is not the cluster's shared scratch/home), the job will fail the moment
the scheduler places it on any node other than the submit node. SLURM gives no
guarantee about which node a job runs on.
Acceptable shared filesystems: NFS, Lustre, GPFS, BeeGFS, any POSIX-compatible network filesystem that is mounted at the same path on all nodes.
Set the shared base path via the environment variable:
export NOVAFABRIC_SLURM_SHARED_DIR=/lustre/scratch/myprojectWhen this variable is set, the live integration tests also use it. When unset,
tests fall back to node-local /tmp, which only works on a single-node cluster.
Python environment on compute nodes
The compute nodes must be able to import novafabric. The recommended approach
is a shared-filesystem venv:
# On the submit node, with the shared FS mounted:
python3 -m venv /lustre/scratch/myproject/nova-venv
/lustre/scratch/myproject/nova-venv/bin/pip install novafabricThen tell the runner which Python interpreter to use:
export NOVAFABRIC_SLURM_VENV_PYTHON=/lustre/scratch/myproject/nova-venv/bin/python3If the venv's site-packages are not self-contained (e.g. you have editable
installs or a non-standard layout), you can also specify the site-packages path
to prepend to PYTHONPATH on the compute node:
export NOVAFABRIC_SLURM_VENV_SITE=/lustre/scratch/myproject/nova-venv/lib/python3.12/site-packagesAlternatively, install NovaFabric at the same absolute path on every compute
node and ensure that path is on the default PYTHONPATH. Shared-FS venv is
simpler and is the validated approach.
sitecustomize injection
This is how wire-level capture fires on compute nodes.
Before submitting the job, SlurmRunner writes sitecustomize.py to the
capsule directory. The capsule directory is on shared filesystem, so it is
visible to the compute node. The batch script (sent on sbatch's stdin) prepends the capsule
directory to PYTHONPATH. When the compute node's Python starts, it
auto-loads sitecustomize, which installs NovaFabric's HTTP hooks before
any user code runs.
Without this injection, the workload runs bare on the compute node: no hooks
install, model-calls.jsonl is created but never written to, and the capsule
appears empty. This was the bug fixed in v0.6.11.
If SlurmRunner cannot write sitecustomize.py to the capsule directory
(e.g. permissions error, filesystem not mounted), the runner returns
runner_status="failed_setup" with a diagnostic message before submitting
the job. It does not silently submit a job that will produce empty capsules.
Environment variables reference
| Variable | Required | Description |
|---|---|---|
NOVAFABRIC_SLURM_SHARED_DIR |
strongly recommended | Base path on shared filesystem for capsule directories and live test isolation |
NOVAFABRIC_SLURM_VENV_PYTHON |
for live tests | Path to Python interpreter in the compute-node-accessible venv; used by the test_sitecustomize_fires_on_compute integration test |
NOVAFABRIC_SLURM_VENV_SITE |
optional | Site-packages path to prepend to compute-node PYTHONPATH |
NOVAFABRIC_SLURM_PARTITION |
per-site | Default partition for the site env template |
NOVAFABRIC_URL_REGISTRY |
optional | Path to a site-local URL registry file (see section 4) |
NOVAFABRIC_CAPSULE_DIR and NOVAFABRIC_SPAN_ID are set automatically by the
orchestrator and exported by the batch script the runner pipes to sbatch. Do not
set them manually when using nova capture.
runner_options for SlurmRunner
Pass via --runner-option key=value on the CLI or via .novafabric/runners.yaml:
| Key | Required | Description |
|---|---|---|
partition |
yes | SLURM partition (queue) to submit to |
account |
no | SLURM account / project (--account) |
qos |
no | Quality of service (--qos) |
time |
no | Wall-clock limit, e.g. "01:00:00" (--time). If unset, derived from --timeout |
nodes |
no | Number of nodes (--nodes). Default 1 |
gres |
no | Generic resources, e.g. "gpu:a100:1" (--gres) |
mem |
no | Memory request, e.g. "16G" (--mem) |
constraint |
no | Node feature constraint (--constraint) |
poll_interval_s |
no | How often to poll for job state, in seconds. Default 5.0 |
output_dir |
no | Where to write slurm-<jobid>.out/.err. Default: capsule directory |
extra_env |
no | Variables to export in the job, e.g. extra_env=LOG_LEVEL=debug,HF_HOME=/scratch/hf, or as a JSON object of strings, extra_env='{"LOG_LEVEL":"debug"}' (needed when a value contains a comma; a malformed one fails setup). The job receives only these, NOVAFABRIC_* (minus credentials) and PATH (ADR-0270, ADR-0311). Names must be shell identifiers |
How the job is submitted
The runner generates a /bin/sh batch script — the export lines, then exec of
your command — and hands it to sbatch on stdin. It is never passed as
sbatch --wrap <script> and never written to the capsule directory: a command line is
readable by every user on the submit host (ps, /proc/<pid>/cmdline), and the
capsule directory is on a shared filesystem and is sealed into the capsule. SLURM
stores the script with the job, where you and the cluster's administrators can read
it (scontrol write batch_script <jobid>).
Every value is single-quoted in the script (shlex.quote), so $, backticks, ; and
& in an extra_env value or a command argument reach the job verbatim; they are not
expanded or executed on the compute node.
Credentials in
extra_env. The value is visible to the cluster's administrators (above), and if you type it on the command line it is innova capture's own argv for the whole run. Prefer a credential the job reads from a file in your home directory with mode0600.
State polling: scontrol and sacct
The runner queries job state using scontrol show job <jobid> as the primary
source and falls back to sacct if scontrol returns nothing (which happens
approximately 5 minutes after a job completes, when slurmctld forgets it).
This two-path design matters because many development and minimal SLURM clusters
do not run slurmdbd. Without slurmdbd, sacct times out or returns no data.
scontrol reads directly from slurmctld memory and works without slurmdbd.
The sacct fallback handles long-running post-completion queries where
scontrol is no longer useful.
If your cluster runs slurmdbd, both paths work. If it does not, the primary
scontrol path handles the common case.
Basic invocation
nova capture \
--runner slurm \
--runner-option partition=gpu \
--runner-option gres=gpu:a100:1 \
--runner-option time=01:00:00 \
--runner-option mem=16G \
python my_agent.pySite environment template
A ready-made environment template is at:
site-config/examples/slurm-hpc/nova-env.shCopy it to site-config/local/slurm-hpc/nova-env.sh (the local/ directory
is gitignored), fill in your site values, and source it before running
nova capture:
cp site-config/examples/slurm-hpc/nova-env.sh site-config/local/slurm-hpc/nova-env.sh
# Edit the file — set NOVAFABRIC_SLURM_SHARED_DIR, NOVAFABRIC_SLURM_VENV_PYTHON, partition
source site-config/local/slurm-hpc/nova-env.sh
nova capture --runner slurm --runner-option partition=$NOVAFABRIC_SLURM_PARTITION python my_agent.pyNever commit the filled-in local/ copy — it contains site-specific paths.
Project-level SLURM defaults in runners.yaml
schema_version: "0.1.0"
default_runner: slurm
runners:
slurm:
partition: gpu
qos: high
constraint: a100
gres: "gpu:a100:1"
mem: "32G"
time: "02:00:00"Running live integration tests against a real cluster
The test suite includes opt-in live tests that require a reachable SLURM cluster:
# Minimal: echo test (no wire capture)
export NOVAFABRIC_SLURM_SHARED_DIR=/home/vagrant
uv run pytest tests/test_runners_slurm.py::TestSlurmRunnerLiveSmoke::test_echo_succeeds -v
# Full wire-capture e2e (requires venv on shared FS)
export NOVAFABRIC_SLURM_SHARED_DIR=/home/vagrant
export NOVAFABRIC_SLURM_VENV_PYTHON=/home/vagrant/novafabric-venv/bin/python3
uv run pytest tests/test_runners_slurm.py::TestSlurmRunnerLiveSmoke -k sitecustomize -vBoth tests were validated against a 3-node cluster running Vagrant + libvirt. See v0.6.12 release notes for the full validation summary.
3.4 Kubernetes (KubernetesRunner)
Works today. The workload runs as a Kubernetes Job. The runner submits the
manifest via kubectl apply, polls for Job completion, retrieves logs via
kubectl logs, copies capsule artifacts back to the submit node via kubectl cp,
and then deletes the Job.
Requirements:
kubectlonPATHconfigured with a reachable cluster (kubectl cluster-infomust succeed).- A target namespace with RBAC permissions for the executing principal:
create,get,watch,deleteonjobsandpods;getonpods/logandpods/exec(forkubectl cp). Nocluster-adminrequired. - A container image that has NovaFabric installed (same requirement as DockerRunner).
Basic invocation:
nova capture \
--runner kubernetes \
--runner-option image=myorg/agent-runtime:abc1234 \
--runner-option namespace=novafabric-runs \
python my_agent.pyAll runner_options for KubernetesRunner:
| Key | Required | Description |
|---|---|---|
image |
yes | Fully-qualified image reference |
namespace |
yes | Target namespace (must exist with RBAC) |
service_account |
no | Pod serviceAccountName (recommended for least-privilege) |
node_selector |
no | Dict of node selector labels |
resources |
no | Container resources block (requests and limits) |
poll_interval_s |
no | Job status poll interval in seconds. Default 2.0 |
extra_env |
no | Dict of env vars to place in the pod. The submitting shell's environment is not inherited — see below |
Example resources block:
runners:
kubernetes:
namespace: novafabric-runs
service_account: novafabric-runner
image: myorg/agent-runtime:latest
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "4"
memory: "8Gi"⚠ Wire-level capture does not run under the Kubernetes runner
--runner kubernetesrecords stdout, exit code and the environment lock, but not model calls or tool calls:model-calls.jsonlandtool-calls.jsonlcome back empty regardless of what the workload did, and the capsule still reportsstatus: success.The runner does not inject NovaFabric's capture hook loader into the pod — its capsule is an
emptyDir, so neither the Docker bind-mount nor the SLURM shared-filesystem mechanism transfers (defect B3,ADR-0272— see the decisions index). As of this release the runner says so on stderr and setsrunner_metadata.wire_capture = "unavailable", so the gap is visible rather than silent — but it is not yet fixed.If you need model-call evidence today, use
--runner docker(fixed in the same release),--runner slurm, or--runner local.
Environment variables (changed in v0.102.0 — ADR-0270): the pod receives
only NOVAFABRIC_* variables plus anything you name in extra_env. The
environment of the shell you ran nova capture in is not forwarded.
Previously every variable of the submitting shell was written into the
Job object as a literal value: — readable by anyone with get job in the
namespace and persisted in etcd. If your workload used to pick up a variable
implicitly, name it explicitly now:
nova capture --runner kubernetes \
--runner-option image=myorg/agent-runtime:abc1234 \
--runner-option namespace=novafabric-runs \
--runner-option extra_env=LOG_LEVEL=debug \
python my_agent.pyDo not put credentials in
extra_env. Its values become literalvalue:entries in the Job object, which is exactly the exposure ADR-0270 closed. Give the pod aservice_accountbound to a KubernetesSecret, or bake the credential into the image's own secret mount, so the value never enters the manifest.
Security posture: The KubernetesRunner sets allowPrivilegeEscalation: false,
privileged: false, hostNetwork: false, hostPID: false, hostIPC: false
on every pod it submits. These are hard-coded in the job manifest builder and
cannot be overridden via runner_options.
Capsule artifact sync: After the workload exits, the runner runs kubectl cp
to copy the in-pod capsule directory (/novafabric/capsule) back to the
submit node. If kubectl cp fails (e.g. the pod exited before the copy ran),
the runner records a warning in stderr but still returns the workload's
exit code. Inspect the capsule directory to determine whether artifacts were
recovered.
Known limitation (v0.6): The KubernetesRunner does not surface the workload's
exact exit code when the Job fails. It reports 0 on succeeded and 1 on
failed. Exact exit codes are queued for a v0.6.x follow-up.
3.5 LSF and PBS/Torque clusters (LSFRunner, PBSRunner) — experimental
Status: experimental. Both are registered, selectable as --runner lsf /
--runner pbs, and covered by unit tests, but they have not been exercised on a
live cluster the way slurm has. They were previously documented nowhere, which
is not the same as not existing — nova capture --runner lsf has been accepted
by the CLI for some time.
Both follow the SLURM model: build a job script, submit it, poll for completion,
and read stdout/stderr back. Wire-level capture works — each uses a fixed
job-script template that puts the hook loader on PYTHONPATH. lsf pipes its
script to bsub on stdin and never writes it to disk, because it carries the
extra_env values (as for slurm, above); pbs takes no extra_env and writes
its script, which holds no values from your environment, to
nova-<run>.pbs in the capsule directory for qsub. Arguments are single-quoted in
both scripts, so the job sees them verbatim.
nova capture --runner lsf \
--runner-option queue=normal \
--runner-option walltime=2:00 \
python train.py
nova capture --runner pbs \
--runner-option queue=default \
--runner-option nodes=2 --runner-option ppn=8 \
--runner-option walltime=02:00:00 \
python train.py| Option | LSF (bsub) |
PBS (qsub) |
|---|---|---|
| queue | queue → -q, default normal |
queue → -q, default default |
| size | n_cores → -n, default 1 |
nodes / ppn → -l nodes=N:ppn=M, default 1/1 |
| wall clock | walltime → -W, H:MM |
walltime → -l walltime=, HH:MM:SS |
| job name | job_name → -J |
job_name → -N |
| resources | resource_requirement → -R |
— |
| poll | poll_interval_s, default 30.0 |
poll_interval_s, default 30.0 |
| logs | output_dir, default capsule dir |
output_dir, default capsule dir |
| environment | extra_env, exported in the stdin script (ADR-0311) |
— (#PBS -v names only) |
Preconditions — not verified by the runner; the job fails loudly if wrong:
- The capsule directory must be on a shared filesystem visible to every
compute host. This is the same requirement as
slurmand the most common cause of an empty capsule. - The compute-host environment must have
novafabricimportable — typically the same virtualenv the submitter uses.
4. URL registry configuration
The URL registry is a YAML file that maps outbound URL substrings to AI provider
labels. Wire-level hooks consult this registry on every outbound HTTP call to
decide whether to record it and how to classify the gen_ai.system field in
model-calls.jsonl.
Bundled defaults
The vendored registry (src/novafabric/capture/hooks/url_registry.yaml)
covers the providers below out of the box:
| Match pattern | Provider label |
|---|---|
api.openai.com |
openai |
api.anthropic.com |
anthropic |
api.cohere.ai |
cohere |
api.together.xyz |
together |
api.mistral.ai |
mistral |
api.replicate.com |
replicate |
bedrock-runtime. |
aws.bedrock (all regions) |
bedrock. |
aws.bedrock (control plane) |
localhost:11434 |
ollama |
127.0.0.1:11434 |
ollama |
Ollama at its default port (11434) is captured with no configuration. AWS
Bedrock is captured across all regions (the match string covers any hostname
containing bedrock-runtime.).
Override hierarchy
The registry is loaded using this precedence (first match wins):
$NOVAFABRIC_URL_REGISTRY— absolute path to a YAML file; explicit per-run override.~/.novafabric/url_registry.yaml— per-user override; applies to all runs by this user.- Vendored file inside the package — applies when neither override is present.
An override file replaces the vendored default entirely. It does not merge.
If your override omits api.openai.com, calls to that endpoint will not be
captured. Always copy in the entries you still need from the vendored file, or
include them explicitly in your override.
Adding a private inference server
Case 1: Ollama on a non-default port
Set OLLAMA_BASE_URL (used by langchain_ollama) or OLLAMA_HOST (used by the
raw ollama SDK) to the actual server address. The URL registry reads these
environment variables at call time, so they work even when loaded by
python-dotenv inside the workload — no manual registry file needed:
# Tunnel on port 11437:
export OLLAMA_BASE_URL=http://localhost:11437
nova capture python agent.py
# Or in a .env file (loaded by python-dotenv in the workload):
echo 'OLLAMA_BASE_URL=http://localhost:11437' >> .env
nova capture python agent.pyIf you prefer an explicit registry entry (e.g. for a shared site config), copy
site-config/examples/local-ollama/url_registry.yaml to
~/.novafabric/url_registry.yaml and add an entry for the port:
schema_version: "0.1.0"
patterns:
- match: "localhost:11437"
gen_ai_system: "ollama"
transport: "http"
- match: "localhost:11434"
gen_ai_system: "ollama"
transport: "http"
- match: "127.0.0.1:11434"
gen_ai_system: "ollama"
transport: "http"
- match: "api.openai.com"
gen_ai_system: "openai"
transport: "http"Case 2: vLLM, TGI, llama.cpp, or any OpenAI-compatible server on a remote host
Use site-config/examples/remote-inference/url_registry.yaml as a starting
point. Set match to host:port of the inference server:
schema_version: "0.1.0"
patterns:
- match: "gpu-node-01.cluster.local:8000"
gen_ai_system: "vllm"
transport: "http"
notes: "vLLM inference server on gpu-node-01"
- match: "api.openai.com"
gen_ai_system: "openai"
transport: "http"Case 3: SSH port-forward or kubectl port-forward to a remote server
If the server is reachable locally via a port forward, match the local address:
- match: "localhost:8001"
gen_ai_system: "vllm"
transport: "http"
notes: "port-forwarded from gpu-node-01:8000"Case 4: Site-wide override for a SLURM cluster
Put the registry file on shared filesystem and reference it in the site env file:
# In site-config/local/slurm-hpc/nova-env.sh:
export NOVAFABRIC_URL_REGISTRY=/lustre/scratch/myproject/nova-url-registry.yamlSource the env file before submitting jobs. The SlurmRunner forwards
NOVAFABRIC_URL_REGISTRY to the compute node's environment via the batch script
(the job receives NOVAFABRIC_* minus credentials),
so the compute node's hooks load the site registry.
Registry file format
The match field is a substring check against the full URL string (scheme,
host, port, and path). The first matching entry wins. Order entries from most
specific to least specific.
schema_version: "0.1.0"
patterns:
- match: "host:port/v1" # more specific path prefix first
gen_ai_system: "myprovider"
transport: "http"
notes: "Optional human-readable note; not emitted in model-calls.jsonl"
- match: "host:port" # broader fallback
gen_ai_system: "myprovider"
transport: "http"Calls to URLs that match no registry entry are not recorded. This is intentional — it avoids capturing unrelated HTTP traffic (health checks, metrics endpoints, object storage) that happens to come from the same process.
5. Troubleshooting
model-calls.jsonl is empty or missing
Cause: sitecustomize.py was not on PYTHONPATH when the workload's Python
started, so the hooks never installed.
For LocalRunner: This should not happen — the runner creates a temporary
directory with sitecustomize.py and injects it via PYTHONPATH before
launching the subprocess. If the file is missing, check whether the workload
spawns its own subprocess that does not inherit PYTHONPATH.
For SlurmRunner: The capsule directory must be on shared filesystem. If the
compute node cannot see capsule_dir, it cannot load sitecustomize.py. Check:
# On the compute node (or via srun):
ls -la /path/to/capsule_dir/sitecustomize.pyIf the file is missing, the capsule directory is not on shared filesystem. Move it to a path that all nodes can reach.
For KubernetesRunner: The image must have NovaFabric installed. The runner
injects PYTHONPATH pointing at /novafabric/capsule inside the pod. If the
image's Python cannot import novafabric, the hook loader will print an error to
stderr and skip capture. Check the pod logs:
kubectl logs <pod-name> -n <namespace>Look for [novafabric] hook install failed: in stderr output.
Capsule directory not visible from compute nodes
Symptom: Job exits with a non-zero code immediately, or
runner_status="failed_setup" with a message about the capsule directory.
Cause: The capsule directory is on node-local storage.
Fix: Set NOVAFABRIC_SLURM_SHARED_DIR to a path on shared filesystem
before running nova capture. The orchestrator will create capsule directories
under that base path.
sbatch submission fails
Check the exit code and stderr returned in RunnerJobResult. Common causes:
partitiondoes not exist on this cluster. Runsinfo -sto list available partitions.- The user does not have permission to submit to the partition. Check with your cluster administrator.
sbatchbinary is not onPATHon the submit node.
which sbatch && sbatch --versionscontrol shows no job after completion
scontrol show job <jobid> returns "Invalid job id" once slurmctld has
forgotten the job (typically 5 minutes after completion). This is normal. The
SlurmRunner uses sacct as a fallback in this window. If sacct also fails,
confirm that slurmdbd is running on the cluster:
systemctl status slurmdbd
sacct -j <jobid> --format=JobID,State,ExitCode --noheaderIf neither tool returns data, the runner will time out polling and return
runner_status="timeout". Capsule artifacts may still be present on shared
filesystem; check the capsule directory directly.
Docker image pull fails
docker run exit code 125 indicates a Docker daemon-level failure, which
includes image pull errors. Check:
docker pull myorg/agent-runtime:abc1234Make sure the image reference is correct and the daemon has pull access
(credentials, registry reachability). The DockerRunner only forwards
NOVAFABRIC_* env vars into the container, not DOCKER_AUTH_CONFIG or
registry credentials. Pre-pull the image before invoking nova capture if
you cannot configure pull credentials at the daemon level.
Kubernetes kubectl cp fails
The kubectl cp step runs after the workload exits. If the pod's container
has already terminated and been garbage-collected before kubectl cp starts,
the copy will fail. This is more likely when:
--timeoutis very short relative to copy time.- The cluster's pod garbage collection (
completedJobsHistoryLimit) runs aggressively.
The capsule does not record the pod name. host.runner.image in capsule.yaml
tells you whether the pod was found: an unresolved_reason of the workload pod was not found means it was not. The Job is named nf-<run-id, lower-cased>, so
while the pod still exists you can find it and re-run the copy by hand:
kubectl get pods -n <namespace> -l job-name=nf-<run-id-lowercase>
kubectl cp <namespace>/<pod>:/novafabric/capsule/. ~/.novafabric/capsules/<run-id>/Wire calls to an unlisted endpoint are not recorded
The URL being called does not match any entry in the active URL registry, so the
wire hooks skip it: no record is written at all (there is no
gen_ai.system=unknown record — each hook records a request only when
UrlRegistry.is_known() is true; capture/hooks/_httpx.py, _requests.py,
_urllib3.py, _aiohttp.py). The symptom is a model call that is missing from
model-calls.jsonl.
Add an entry to your URL registry override (see section 4). Then check the active registry path:
# Which registry file is being loaded?
python3 -c "
from novafabric.capture.hooks._url_registry import load_url_registry, _VENDORED_DEFAULT_PATH
import os
env = os.environ.get('NOVAFABRIC_URL_REGISTRY', '')
user = os.path.expanduser('~/.novafabric/url_registry.yaml')
print('NOVAFABRIC_URL_REGISTRY:', env or '(not set)')
print('user override exists:', os.path.exists(user))
print('vendored default:', _VENDORED_DEFAULT_PATH)
"5a. Server environment variables
Every value below is read from the environment by nova server. All defaults are
the ones the code actually applies, read from a default ServerConfig rather
than transcribed. Safety-off switches are not here — they are in
SECURITY.md §Escape hatches, deliberately in one place.
Outbound webhooks
Disabled by default. This is the only path that leaves the trust boundary.
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_SERVER_WEBHOOKS_ENABLED |
false |
Master switch |
NOVAFABRIC_SERVER_WEBHOOKS_QUEUE_MAX |
1000 |
Bounded queue; beyond it deliveries are shed rather than buffered without limit |
NOVAFABRIC_SERVER_WEBHOOKS_MAX_ATTEMPTS |
5 |
Retries before a delivery is abandoned |
NOVAFABRIC_SERVER_WEBHOOKS_TIMEOUT_S |
5.0 |
Per-attempt HTTP timeout |
NOVAFABRIC_SERVER_WEBHOOKS_DELIVERY_RETENTION_DAYS |
30 |
Delivery-record age cap |
NOVAFABRIC_SERVER_WEBHOOKS_DELIVERY_RETENTION_ROWS |
10000 |
Delivery-record row cap — whichever bites first |
Usage metering
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_SERVER_USAGE_METERING_ENABLED |
true |
Master switch |
NOVAFABRIC_SERVER_API_KEYS_ENFORCE_WORKSPACE_BINDING |
false |
Experimental (ADR-0294): refuse (403) a workspace-bound API key whose workspace does not exist or whose request names another workspace (quotas §per-workspace) |
NOVAFABRIC_SERVER_USAGE_FLUSH_INTERVAL_S |
60.0 |
How often the in-memory accumulator is flushed |
NOVAFABRIC_SERVER_USAGE_ACCUMULATOR_MAX_ENTRIES |
10000 |
Bound on the accumulator between flushes |
NOVAFABRIC_SERVER_USAGE_ROLLUP_RETENTION_MONTHS |
24 |
Aggregated rollups |
NOVAFABRIC_SERVER_USAGE_LEDGER_RETENTION_MONTHS |
3 |
Raw ledger rows — deliberately shorter than the rollups |
Step-up re-authentication
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_SERVER_STEP_UP_ENABLED |
false |
Require re-auth for privileged actions |
NOVAFABRIC_SERVER_STEP_UP_MAX_AGE_SECONDS |
300 |
How long a step-up stays valid |
Storage and identity
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_DB_URL |
— | Not read by nova serve (its /api/health backend_type reports the SQLite registry it actually opens; before 2026-10-23 this variable flipped that label to postgres). It is not a migration target: nova migrate-to-postgres --target and nova db read NOVAFABRIC_POSTGRES_DSN |
NOVAFABRIC_API_WORKERS |
1 |
⚠ Read by SQLiteMetadataStore, which refuses to construct when this is > 1. It is the guard behind "SQLite cannot be shared across writer processes" — see §3 on --workers |
NOVAFABRIC_AUDIT_LOG_PATH |
$XDG_DATA_HOME/novafabric/audit.jsonl when XDG_DATA_HOME is set, else ~/.local/share/novafabric/audit.jsonl (NOVAFABRIC_HOME deliberately does not move it) |
Hash-chained audit log path. Every writer and reader (exports, promotions, holds, nova audit-log, nova policy explain, nova serve, backup) resolves it the same way, at the moment it is used |
Rate limiting
Off by default. Per-class token buckets; the audit keys fire when rejections cross a threshold inside a window, so a burst is logged once rather than per request.
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_SERVER_RATE_LIMITS_ENABLED |
false |
Master switch. Class defaults when on: ingest 100/s burst 200, read 50/s burst 100, admin 10/s burst 20 |
NOVAFABRIC_SERVER_RATE_LIMITS_AUDIT_THRESHOLD_REJECTIONS |
100 |
Rejections before an audit event |
NOVAFABRIC_SERVER_RATE_LIMITS_AUDIT_WINDOW_SECONDS |
60 |
Window the threshold is counted over |
Observability
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_SERVER_SELF_TRACING |
false |
Emit NovaFabric's own OTel spans |
NOVAFABRIC_SERVER_SELF_TRACING_ENDPOINT |
— | OTLP destination for those spans |
NOVAFABRIC_SERVER_METRICS_EXEMPT |
false |
Exempt /metrics from auth |
NOVA_BUILD_SHA |
— | Build SHA reported in observability output |
NOVA_COLLECTOR_METRICS_URL |
http://localhost:9464/metrics |
Where the dashboard scrapes collector metrics |
Cluster collector receiver (collector_app)
Read by novafabric.collector_app:create_collector_app (the Python POST /capsule
receiver), not by nova server. Full walkthrough:
cluster-scale tutorial.
| Variable | Default | Effect |
|---|---|---|
NOVA_COLLECTOR_TOKEN |
unset (local mode) | Every POST /capsule must send X-Nova-Token: <token>, from any address. Unset: only loopback callers with a localhost Host and no proxy header are served, everyone else gets 403 |
NOVA_COLLECTOR_TENANT |
default |
The tenant the caller is authenticated as; every event is stored under it |
Each event in a batch needs a string event_type; a malformed event (no event_type,
a field of the wrong type, a token count out of range) refuses the whole batch with
422 naming the field. The full event contract is in the
cluster-scale tutorial.
⚠ Set
NOVA_COLLECTOR_TOKENwhenever anything proxies to the collector. Local mode refuses a request carryingForwarded,X-Forwarded-*,X-Real-IPorVia, but it cannot see a same-host reverse proxy that adds none of them — nginx'sproxy_pass http://127.0.0.1:8090;with its defaults connects from loopback, sendsHost: 127.0.0.1:8090and no forwarding header. Behind such a proxy every remote caller looks local and is served without authentication. No setting other than a token closes this; the startup warning says so too.
API shape
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_SERVER_BULK_MAX_ITEMS |
100 |
Cap on items per bulk request |
NOVAFABRIC_SERVER_PAGINATION_LEGACY_OFFSET_CURSORS |
true |
Keep accepting legacy offset cursors alongside the current form |
NOVAFABRIC_ALLOW_SEALED_DELETE |
off | Experimental (ADR-0206 P2): exactly 1 lets DELETE /v0/capsules/{id}, bulk delete and nova serve's DELETE /api/runs/{id} remove a NovaSeal-sealed capsule instead of refusing with 409 sealed_capsule; never overrides a legal hold or WORM lock. Refusals and removals are audited (run.index_delete_refused, run.index_delete) |
NOVA_SERVE_MAX_FILE_BYTES |
5000000 |
nova serve per-file read cap |
Object store and paths
Home resolution (works today). Every home-relative path resolves, in order:
the path's own override variable (e.g. NOVAFABRIC_DB_PATH, NOVAFABRIC_CAPSULE_DIR,
NOVAFABRIC_EVIDENCE_DIR) > $NOVAFABRIC_HOME/<name> > ~/.novafabric/<name>. This
covers the registry, capsules, evidence, the keystore (keys/, which the health panel's
keystore_ok checks), tokens, the object-store WAL, the dashboard audit log and the
NovaSeal config and nonce store. Unset NOVAFABRIC_HOME and the defaults are exactly the
old ~/.novafabric locations. Deliberately not under the home: the per-user keyring
(~/.config/novafabric; move it with NOVAFABRIC_KEYRING_DIR), the legacy seal stores (~/.local/share/novafabric), and
project-local ./.novafabric/ directories — and the hash-chained audit log, which stays at
$XDG_DATA_HOME/novafabric/audit.jsonl, or ~/.local/share/novafabric/audit.jsonl when
XDG_DATA_HOME is unset, whatever NOVAFABRIC_HOME says: pointing a shell at another data
home must not hide the existing trail or start a second hash chain beside it. Move it
explicitly with NOVAFABRIC_AUDIT_LOG_PATH (deployments put it on the data volume that way).
| Variable | Default | Effect |
|---|---|---|
NOVA_S3_ACCESS_KEY / NOVA_S3_SECRET_KEY |
— | ⚠ Credentials. Prefer an instance role or a mounted secret; a value here is visible to anything that can read the process environment |
NOVA_S3_GOVERNANCE_BUCKET / NOVA_S3_COMPLIANCE_BUCKET |
— | Buckets for the dual object store |
NOVA_OBJECT_STORE_ENCRYPTION |
off unless 1 |
Enables backup object encryption |
NOVA_OBJECT_STORE_ALLOW_PLAINTEXT_READS |
off | Legacy-migration opt-in: an encrypted object store returns non-envelope (pre-encryption) objects instead of refusing them with PlaintextObjectRefusedError (encryption-at-rest §2.1) |
NOVA_OBJECT_STORE_LEGACY_INVENTORY |
— | Experimental (ADR-0295): path to a digest-pinned legacy-object inventory; only listed pre-encryption objects whose stored bytes are unchanged are returned (encryption-at-rest §2.1.1) |
NOVA_OBJECT_STORE_LEGACY_INVENTORY_SHA256 |
— | Optional SHA-256 pin of the inventory file; a mismatch refuses startup |
NOVA_OBJECT_STORE_REFUSE_V1_ENVELOPES |
off | Experimental strict mode (ADR-0295): refuse unbound v1 envelopes not pinned in the inventory (LegacyEnvelopeRefusedError) |
NOVAFABRIC_BACKEND |
sqlite |
Storage backend selector |
NOVAFABRIC_JOBS_DB |
— | Jobs database path override |
NOVAFABRIC_KEYRING_DIR |
~/.config/novafabric/keyring |
Promote keyring directory (maker-checker signing keys); resolved when used, never at import |
NOVAFABRIC_LOG_DIR |
— | Log directory the support bundle collects from |
NOVA_EVIDENCE_DUCKDB_PATH |
— | Evidence DuckDB path |
Capture
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_CAPTURE_SOCKET |
— | Capture daemon socket path override |
NOVAFABRIC_FAST_EMIT |
off unless 1 |
Fast-emit mode inside the captured process |
NOVAFABRIC_CAPTURE_STRICT |
off | Truthy (1/true/yes/on): refuse a capture that would overlap another in the same process (ConcurrentCaptureRefused) instead of producing a contended capsule (ADR-0224 OQ-2, works today, opt-in) |
NOVAFABRIC_OTLP_LOG_DIR |
$NOVAFABRIC_HOME/otlp-logs |
Experimental (ADR-0293): sidecar store for POST /api/otlp/v1/logs; outside every capsule, not sealed evidence — back it up separately |
NOVAFABRIC_OTLP_LOGS_STORE_BODY |
off | Experimental: also store OTLP log body text and attribute values (secret-redacted, truncated); default stores metadata and a body SHA-256 only |
Other
| Variable | Default | Effect |
|---|---|---|
NOVAFABRIC_SERVER_TOKEN |
— | Pin the local auth token instead of letting the server generate one (see §5d) |
NOVAFABRIC_WEBHOOKS_QUEUE_MAX |
1000 |
Accepted as an alias of NOVAFABRIC_SERVER_WEBHOOKS_QUEUE_MAX |
NOVA_NATS_DUPLICATE_WINDOW_S |
120 |
Lineage consumer de-duplication window |
NOVAFABRIC_TRUTHFUL_QA_OCI_IMAGE |
— | TruthfulQA eval suite image reference. Required, no default (no eval image is published; ADR-0308) |
NOVAFABRIC_TRUTHFUL_QA_OCI_DIGEST |
— | Pins that image to an exact digest. Required, no default (ADR-0308) |
5b. NovaSeal configuration (cryptographic signing)
NovaSeal is the sealing layer beneath the Evidence Bundle: it signs a capsule with
an ECDSA P-256 DSSE signature, optionally timestamps it via RFC 3161 (opt-in:
set tsa_url, there is no default TSA, ADR-0292), and records an
inclusion proof in an append-only Merkle log (the proof is also carried in the capsule's
log-entry.json, so nova verify works on an auditor's machine with no config and no log).
nova verify checks every layer present and exits 0 only if the capsule is unmodified
since signing; a layer that was not requested prints NOT PRESENT rather than failing. This is what
turns "here is a capsule" into "here is a capsule I can prove was not tampered with."
NovaSeal is opt-in — if $NOVAFABRIC_HOME/novaseal.yaml
(~/.novafabric/novaseal.yaml when NOVAFABRIC_HOME is unset) and the
NOVAFABRIC_SEAL_CONFIG environment variable are both absent, capture behaves exactly
as before and no sealing occurs. This keeps the default path zero-dependency and
air-gap friendly.
For the full configuration reference (all profiles, env vars, Docker/SLURM patterns, path resolution order, troubleshooting) see: docs/novaseal-configuration.md
Quick start — one command (experimental, ADR-0301): nova seal init creates a
dedicated P-256 key, a local seal CA and a certificate under
$NOVAFABRIC_HOME/keys/novaseal/ and writes a managed novaseal.yaml with no
tsa_url (offline). Its identity is self-asserted; nova verify reports it as
such. Rotate with nova seal init --force. It never replaces a novaseal.yaml it did
not write, so the manual and KMS configurations below are unaffected.
Manual — your own key + minimal config:
mkdir -p ~/.novafabric
openssl ecparam -name prime256v1 -genkey -noout | \
openssl pkcs8 -topk8 -nocrypt -out ~/.novafabric/seal.key
openssl req -new -x509 -key ~/.novafabric/seal.key -days 3650 \
-out ~/.novafabric/seal.crt -subj "/CN=NovaSeal-$(hostname)"# ~/.novafabric/novaseal.yaml
profile: local
key_path: ~/.novafabric/seal.key
cert_path: ~/.novafabric/seal.crt
tsa_url: https://freetsa.org/tsr # opt-in (ADR-0292): omit for no timestamp / no network call
merkle_db: ~/.novafabric/novaseal-merkle.dbSLURM / shared-FS deployments: set merkle_db: to a path on the shared
filesystem. See novaseal-configuration.md §3.3.
Docker deployments: set NOVAFABRIC_SEAL_CONFIG to the mounted config path
and configure merkle_db: inside it. See novaseal-configuration.md §3.2.
Verify a sealed capsule:
nova verify /path/to/capsule/
# Five checks over the capsule and its .seal/ directory:
# • ECDSA P-256 DSSE signature → signature_ok=True
# • RFC 3161 timestamp → timestamp_ok=True (None when no token: absent, not failed);
# timestamp_trust=anchored | unanchored | absent | invalid
# • Merkle log inclusion proof → log_integrity_ok=True (plus the sealer's own log when configured)
# • manifest binding → capsule.yaml agrees with the signed payload
# • evidence binding → every file's sha256 matches evidence_digests
# It also reports the signer trust level (identity_trust=self-asserted | local-ca-pinned |
# ca-anchored) and fails on recorded workload tampering or a link/special file in the capsule.
# Exits 0 if all checks pass, 1 otherwise, 2 on a usage error (an incompatible flag
# combination). --json prints one JSON object.
# Explicit config path (instead of $NOVAFABRIC_HOME/novaseal.yaml):
nova verify --seal-config ~/configs/novaseal.yaml /path/to/capsule/Network note: The TSA request (tsa_url) requires outbound HTTPS on port 443.
On air-gapped clusters, set tsa_url: "" to skip timestamping, or run a private
TSA inside the cluster and point tsa_url at it.
5d. Server credentials: which token, and what the server must be told
nova server accepts three credential classes. Picking the wrong one, or
configuring only half of one, is the most common first-multi-node-deploy failure
(defect B6) — so this section states both halves of each.
| Credential | Shape | Server needs | Use for |
|---|---|---|---|
| Local token (ADR-0184) | opaque string | nothing — generated at startup | single-node, loopback |
| Offline JWT (ADR-0018/0178) | three dot-separated segments | NOVAFABRIC_OFFLINE_KEY_PATH |
multi-node, airgapped, Slurm |
| API key | opaque string | key store | long-lived integrations |
The local token — single node
Generated on first start, stored at $NOVAFABRIC_HOME/.server-token (mode 0600).
It is printed only when stderr is a terminal. Under nohup, systemd, Docker,
Kubernetes or CI, stderr is a durable, routinely-collected file, so a
non-interactive start prints a fingerprint and the path instead — read the
token from the file. That is deliberate (ADR-0263): the token used to land in
plaintext on line 1 of every server log.
Offline JWTs — multi-node
Issue one on the machine holding the signing key:
nova server issue-token --subject bench@example.com --roles writer,reader --expires-in 30d--key-path defaults to NOVAFABRIC_OFFLINE_KEY_PATH, else
~/.novafabric/keys/offline-key.pem; a keypair is generated if absent. The token
goes to stdout, so TOK=$(nova server issue-token …) captures only the token.
The half that is easy to miss. The server ignores offline JWTs entirely unless it is started with
NOVAFABRIC_OFFLINE_KEY_PATHset. Without it a perfectly valid token is rejected — and until this was fixed the 401 said "Invalid local token", sending operators to check the wrong file for a credential they never presented.NOVAFABRIC_OFFLINE_KEY_PATH=~/.novafabric/keys/offline-key.pub nova server startGive the server the
.pub, not the private key. Verification accepts either — it derives the public key from a private one if no.pubsits beside it — but shipping the signing key to every server defeats the point of offline signing. Issue tokens on one trusted machine; distribute only the public half.
Revoke by token id with nova server revoke-token.
Precedence
API key first, then offline JWT (only when the key path is configured and the bearer is JWT-shaped), then the local token. A JWT-shaped credential is never compared against the local token — that is what makes the 401 able to name the class you actually presented.
5c. SAML 2.0 SSO (server mode — experimental, partial)
Status: experimental (ADR-0138,
spec). Config, SP metadata, role mapping, and the
assertion validation policy work today. Live SAML login (assertion consumption at
the ACS) shipped in v0.73.0 but is off by default and requires an explicit
opt-in — see the honest status below. SAML is a server-mode-only concern:
local-first mode (nova capture|validate|replay|diff|lineage) never touches it,
and pip install novafabric installs zero SAML dependencies.
Enabling the backend
Add an optional saml: block to the server YAML config
(~/.config/novafabric/server.yaml or --config). Absent block ⇒ backend
disabled ⇒ behavior identical to an OIDC-only deployment.
saml:
enabled: true
sp_entity_id: "https://nova.example.org/saml/metadata"
acs_url: "https://nova.example.org/v0/auth/saml/acs"
sp_cert_path: /etc/novafabric/saml/sp.crt
sp_key_path: /etc/novafabric/saml/sp.key # mode 0600, never logged
subject_attribute: email # optional; default: NameID
allow_idp_initiated: false # default false (more replay-exposed)
clock_skew_seconds: 120 # hard cap 300
experimental_acs_enabled: false # default false; see honest status below
idp:
entity_id: "http://www.okta.com/exk1abcXYZ"
sso_url: "https://example.okta.com/app/nova/exk1abcXYZ/sso/saml"
x509_cert_path: /etc/novafabric/saml/okta.crt
attribute_role_map:
attribute: groups
mapping:
nova-admins: [admin]
nova-writers: [writer]
nova-auditors: [auditor, reader]
default_roles: [reader] # never defaults to adminThe config is a closed schema: unknown keys, roles outside the six existing
ADR-0018/ADR-0058 roles (reader,writer,admin,auditor,promoter,approver), and
clock_skew_seconds > 300 are rejected at load time. Unmapped attribute values
fail closed to default_roles (default []) — never to admin. If
attribute_role_map is absent, roles come from the existing local
role-assignment table (nova server assign-role), same as the OIDC fallback —
which is consulted only once the workspace/org model is in use (ADR-0178);
assign-role warns when a row is not in force.
Registering NovaFabric with the IdP
nova server saml-metadata --config /etc/novafabric/server.yaml > sp-metadata.xml
# or, from a running server:
curl https://nova.example.org/v0/auth/saml/metadataHand sp-metadata.xml to the IdP administrator. When sp_cert_path points at
a readable PEM certificate, the metadata embeds it as the SP signing key.
Honest status — what works and what refuses
| Piece | Status |
|---|---|
server.saml config block (closed schema, role allow-list, skew cap) |
works today |
nova server saml-metadata + GET /v0/auth/saml/metadata |
works today |
Attribute→role mapping and assertion validation policy (issuer, audience, time bounds, recipient, replay store, InResponseTo, status — spec rules V3–V9, V11) |
implemented and tested against synthetic assertions |
GET /v0/auth/saml/login, POST /v0/auth/saml/acs — assertion verification |
experimental, opt-in. Refuses with HTTP 501 (saml_not_available) unless saml.experimental_acs_enabled: true and the novafabric[saml] extra is installed. Opted in, the ACS verifies the assertion (401 on any failed rule) |
| SAML login session | planned — no credential exists yet: a verified assertion is answered HTTP 501 saml_session_unavailable (details.assertion_verified: true), not a token. Until then authenticate with OIDC, an API key or an offline token |
| Single Logout (SLO) | future design (ADR-0138 P4) |
Why it is off by default: XML-DSIG signature verification (spec rules V1/V2)
and the XXE-hardened parser (V10) require a SAML library, and ADR-0138 §D5 held
that library as a pre-adoption gate. The gate closed in v0.73.0: signxml
(Apache-2.0) + lxml (BSD) passed the ADR-0024 license audit as Tier A, with no
native xmlsec/libxml2 bundle to audit. Hand-rolling XML signature validation
remains forbidden, and NovaFabric still refuses to consume assertions rather
than skip signature validation — without the opt-in (or with the library
missing) the ACS never parses the posted XML.
Enabling it is a deliberate act:
pip install 'novafabric[saml]' # signxml + lxml
# then set saml.experimental_acs_enabled: true in the server YAMLSecurity-Architect review is a pre-production blocking condition for this path. Setting
experimental_acs_enabled: trueacknowledges the pre-review status; do not enable it in production before that review.
If your IdP estate can speak OIDC, use the shipped OIDC backend (ADR-0018) today; an OIDC bridge (Keycloak/Dex fronting the SAML IdP) remains a documented interim option.
6. What is not supported yet
Corrected 2026-07-30 — this table had gone stale. Several rows below had shipped since this section was last updated (some as far back as the v0.7x/v0.9x releases) but the table still said "planned" / "future design" against a "v0.2" target. Re-verified against the code in this checkout; still-genuine gaps are listed below the shipped-items note.
Shipped since this table was last accurate (do not re-plan these):
| Capability | Real status | Where |
|---|---|---|
| NovaSeal: Sigstore keyless signing | experimental, works today | --backend sigstore (§5, §6 troubleshooting above); needs novafabric[sigstore] extra + network to Fulcio/Rekor — not usable air-gapped (air-gapped guide) |
| NovaSeal: Cloud KMS (AWS KMS, Azure KV, GCP KMS) | experimental, works today | Signing backends AwsKmsSigningBackend/AzureKvSigningBackend/GcpKmsSigningBackend (novafabric[seal-aws|seal-azure|seal-gcp]); the parallel envelope-encryption wrapping backends (AwsKmsWrappingBackend etc., ADR-0185) are separately shipped — see encryption-at-rest.md §4. Verified against unit tests and in-memory SDK fakes; end-to-end verification against live cloud credentials is the one piece still outstanding |
| NovaSeal: Postgres Merkle log | experimental, works today | PostgresMerkleLog (trust/novaseal/merkle.py, novafabric[seal-postgres]); pass a postgresql:// DSN as merkle_db: (a libpq host=… password=… keyword string is refused, never opened as a file name). verify_consistency() on this backend is a sampled check, not full re-hash |
| Parent/child capsule relationships | implemented, tested | src/novafabric/capsule/{tree_assembler,env_contract,orphan,edge_typer,writer,schema}.py + schemas/parent_child_capsule_v1.schema.json (257 passing tests in tests/capsule/) |
| SAML SSO: live login (assertion consumption at the ACS) | experimental, opt-in | The ADR-0138 §D5 library gate closed in v0.73.0 (signxml + lxml, Tier A). POST /v0/auth/saml/acs consumes assertions when saml.experimental_acs_enabled: true and novafabric[saml] is installed; otherwise it still refuses with 501. Security-Architect review remains a pre-production blocking condition — see §5c. A verified assertion is answered 501 saml_session_unavailable: no SAML login session exists yet |
Genuinely still not implemented:
| Capability | Status | Target |
|---|---|---|
| NovaSeal: X.509 HSM / PKCS#11 (hardware-backed key custody) | planned | unscheduled. Note this is distinct from the already-shipped x509 profile (ADR-0055, trust/novaseal/x509_identity.py) — that ships a certificate-pinned signing identity with the key as a local PEM file, not hardware-backed custody |
| SAML SSO: Single Logout (SLO) | future design (ADR-0138 P4) — the slo: config block validates, but no SLO endpoint exists |
unscheduled |
| Federation across clusters | future design (ADR-0021 §federation-topology) | unscheduled (the "v0.9+" target in earlier drafts of this table has passed without this shipping) |
| interLink K8s-to-SLURM integration | proposed (ADR-0028, no code) | unscheduled |
| Third-party runner plugins (AWS Batch, Modal, etc.) | planned (ADR-0025 §B) | unscheduled |
| Registry merge semantics (override + vendored combined) | planned | unscheduled |
Do not configure production workflows that depend on any of the above. If you need one of these capabilities, open an issue or follow the RFC process described in the RFC process.
7. Docker Compose deployment (nova serve + dashboard)
The bundled deploy/docker/docker-compose.yml runs the full dev stack
(Postgres + nova-serve) in a single command. This section documents every
data-path decision you must get right for a correct deployment; mistakes here
result in silent empty tabs in the dashboard rather than error messages.
7.1 Stack profiles
| Profile | Services started | Command |
|---|---|---|
| (none — default) | postgres + nova |
make dev-up |
prod |
+ ClickHouse + NATS + Kafka + PgBouncer + JanusGraph | make prod-up |
Both profiles are defined in deploy/docker/docker-compose.yml. The nova
service depends on postgres being healthy before it starts.
make updatenote: rebuilds thenovaimage and does a rolling restart with--no-deps, so it intentionally does not restart Postgres. If Postgres is not already running (e.g. first deploy after a reboot), runmake dev-upinstead, or thenovacontainer will wait forever for the DB.
7.2 Volume mounts
The nova service uses three mounts. All host paths default to
~/novafabric-data/ and can be overridden with NOVA_DATA_DIR.
| Container path | Default host path | Purpose |
|---|---|---|
/data/capsules |
$NOVA_DATA_DIR/capsules |
Run capsule directories (written by nova capture, read by all tabs) |
/data/nova |
$NOVA_DATA_DIR/nova |
NOVAFABRIC_HOME inside the container — registry DB, serve token, audit log |
Ownership (non-root image): the container runs as
nova(uid/gid 1000 — the same uid the Helm chart pins inpodSecurityContext). Docker creates a missing bind-mount host path as root, and a root-owned/data/*surfaces as a loud permission error on the container's first write. Pre-create$NOVA_DATA_DIRas a user-owned directory (the default~/novafabric-dataalready is), orchown -R 1000a path that was created by an older, root deployment. |/data/kuzu| Docker named volumekuzu-data| KuzuDB knowledge graph store |
All testbench workloads must write capsules to the host path that maps to
/data/capsules. Do not use a relative capsules/ path or a symlink inside
the testbench; set NOVAFABRIC_HOME (or NOVAFABRIC_CAPSULE_DIR) explicitly.
See deploy/docker/docker-compose.yml for the exact mount syntax.
7.3 Data-path environment variables
The following variables control where the nova process inside the container
reads and writes data. Wrong values cause dashboard tabs to show empty results
without any error.
| Variable | Value in Docker | Default outside Docker | Effect |
|---|---|---|---|
NOVAFABRIC_HOME |
/data/nova |
~/.novafabric |
Registry DB, serve token, audit log root |
NOVAFABRIC_EVIDENCE_DIR |
/data/capsules/evidence |
$NOVAFABRIC_HOME/evidence (~/.novafabric/evidence) |
Where nova export-evidence writes ZIPs; where the Evidence tab reads them |
NOVAFABRIC_SERVE_PATH_ROOTS |
(unset) | (unset) | Extra absolute directories (:-separated) that nova serve may read and write when a request names a path. Without it, writes are confined to $NOVAFABRIC_HOME/exports, $NOVAFABRIC_HOME/evidence and $NOVAFABRIC_EVIDENCE_DIR, and reads also to $NOVAFABRIC_HOME and the capsule dir. Reads never reach the server's secrets (token stores, .env, dek.db, ratchet state, config.yaml, audit logs, and for source paths key material), even under a listed root. Replaces NOVAFABRIC_SERVE_ALLOW_ANY_PATH, which is no longer honoured |
NOVAFABRIC_METADATA_BACKEND |
postgres |
sqlite |
MetadataStore backend |
NOVAFABRIC_METADATA_DSN |
postgresql://nova:nova@postgres:5432/nova |
(SQLite file) | DB connection string |
NOVAFABRIC_SERVER_BACKEND |
postgres |
sqlite |
Server-mode (nova server start) backend selector: sqlite or postgres (overrides backend: in server.yaml) |
NOVAFABRIC_POSTGRES_DSN |
postgresql://nova:nova@postgres:5432/nova |
— | The Postgres DSN for server mode (when the backend is postgres), nova db and nova migrate-to-postgres, and the fallback DSN for nova backup create --profile pg and nova restore of a pg set when NOVA_DSN is unset |
NOVA_WATCHER_BACKEND |
auto |
auto |
CapsuleWatcher backend: auto (watchdog if installed, else polling), polling, watchdog. Any other value stops nova serve at startup (exit 2). An explicit nova ingest-capsule --backend wins. |
NOVA_WATCHER_INTERVAL |
2.0 |
2.0 |
Poll interval in seconds for PollingBackend and nova ingest-capsule --watch; must be a finite number > 0. An explicit --interval wins. |
Why
NOVAFABRIC_EVIDENCE_DIRmatters: Without it the serve app uses$NOVAFABRIC_HOME/evidence(_paths.evidence_dir) — in the compose stack/data/nova/evidence, which is on the persistent volume, but is not where the testbench export stage writes. The compose file sets/data/capsules/evidence(inside the capsule volume) so the Evidence tab and the testbench export stage share one directory.
7.4 Fresh deployment checklist
When deploying to a new host for the first time:
# 1. Clone and pull
git clone https://github.com/MSKazemi/novafabric.git
cd novafabric
git pull
# 2. Create the data directories on the host
mkdir -p ~/novafabric-data/capsules ~/novafabric-data/nova
# 3. Start the full stack (builds image, starts postgres + nova-serve)
make dev-up
# prints: Dashboard: http://localhost:4321/dashboard?token=<token>
# 4. (Optional) SSH tunnel if running on a remote server
# Run on your laptop:
ssh -N -L 4321:localhost:4321 <user>@<server> &
open http://localhost:4321/dashboard?token=<token>If the dashboard opens but tabs are empty, check:
- Runs tab empty — no capsules in
~/novafabric-data/capsules/on the host, or theruns_cacheindex is stale. Force a full re-index:docker compose exec nova uv run nova ingest-capsule --all # or from the host: nova ingest-capsule --all --capsule-dir ~/novafabric-data/capsulesnova servealso re-indexes on startup and every 2 s viaCapsuleWatcher(NOVA_WATCHER_BACKEND=auto,NOVA_WATCHER_INTERVAL=2.0). - Evidence tab empty —
NOVAFABRIC_EVIDENCE_DIRnot set or pointing at the wrong path. Verify with:docker compose exec nova sh -c 'echo $NOVAFABRIC_EVIDENCE_DIR' - Container stuck at "waiting for postgres" — Postgres not running.
Run
make dev-up(notmake update) to bring up the full stack.
7.5 Remote access via SSH tunnel (make nova-dashboard)
The lpt/ orchestrator project includes a nova-dashboard Makefile target
that automates the SSH tunnel + token fetch in one command:
# From ~/scratch/lpt/ on your laptop:
make nova-dashboard
# → ensures postgres + nova are up on the server
# → fetches the live token
# → opens an SSH tunnel on localhost:4321
# → prints the dashboard URL
# → keeps tunnel open until Ctrl-CThe target uses docker compose exec nova (service name, not container name)
to read the token — this is robust to container renames across deploys.
7.6 Deployment mode: dashboard or server (NOVA_MODE)
Status: works today. The bundled entrypoint runs one of two processes,
selected by NOVA_MODE:
NOVA_MODE |
Runs | Auth |
|---|---|---|
dashboard (default) |
nova serve --experimental --insecure --topology --tv5 — the experimental dashboard over HTTP with a printed token |
Session token only; put TLS in front of it and never expose it directly |
server |
nova server start --backend postgres — the multi-user REST API |
On by default (OIDC via NOVAFABRIC_SERVER_*, else a generated local bearer token). The entrypoint never passes --insecure-no-auth |
Both modes run the Alembic migration first (nova db upgrade --backend postgres --revision ${NOVA_DB_REVISION:-head}) and both expose /livez, which is what
the Compose healthcheck probes.
| Variable | Default | Effect |
|---|---|---|
NOVA_MODE |
dashboard |
Selects the process above |
NOVA_PORT |
4321 (dashboard) / 7433 (server) |
Listen port. The bundled docker-compose.yml publishes only 127.0.0.1:4321, so with NOVA_MODE=server also set NOVA_PORT=4321 or add your own port mapping |
NOVA_WORKERS |
1 |
nova server start --workers N — uvicorn worker processes (server mode only; needs Postgres) |
NOVA_DB_REVISION |
head |
Alembic revision to upgrade to. The server refuses to start when stamped behind head, so pinning below head breaks a server-mode deploy |
The Helm chart exposes the same switch as mode: dashboard | server with
server.workers (see deploy/helm/novafabric/values.yaml). Unlike the Compose
entrypoint, the chart never adds --insecure on its own: mode: dashboard
renders only with serve.insecure: true plus
serve.acknowledgeInsecureExposure (ADR-0230), and helm template refuses
otherwise rather than produce a pod that nova serve would refuse to start.
8. Summary and next steps
You now have the operational picture for deploying NovaFabric across every runner:
- One mechanism, four runners. Local, Docker, SLURM, and Kubernetes all rely on
the
sitecustomize.pyhook loader onPYTHONPATH(§1). The runner's only responsibility is to make the capsule directory writable and reachable and to return artifacts to the submit node. - The single most important SLURM constraint is that the capsule directory must live on a filesystem shared by the submit node and every compute node (§3.3). Get this wrong and jobs fail the moment the scheduler places them off the submit node.
- Wire-level classification is driven by the URL registry (§4). Private
inference servers (Ollama, vLLM, TGI, llama.cpp) are captured by adding a
host:portmatch; an override file replaces the vendored default entirely, so copy in the entries you still need. - Sealing is opt-in (§5b). Without
novaseal.yaml, capture is unchanged; with it,nova verifyproves signature, timestamp, and Merkle-log integrity.
Suggested first-run checklist
pip install novafabric(oruv pip install novafabric) on Python 3.12+.- Capture a local run:
nova capture python my_agent.py. - Validate it:
nova validate ~/.novafabric/capsules/<run-id>/. - Confirm wire capture fired: check that
model-calls.jsonlis non-empty; if not, see §5 "model-calls.jsonl is empty or missing". - For SLURM/K8s, verify the compute-node environment can
import novafabricand that the capsule directory is on shared storage before scaling out.
Where to go next
| If you want to… | See |
|---|---|
| Look up any command, flag, or default | docs/cli-reference.md |
| Configure signing in depth (profiles, KMS, air-gap) | docs/novaseal-configuration.md |
| Understand SLURM validation history | v0.6.12 release notes |
| Contribute code or run the test suite | CONTRIBUTING.md |
| Request an unsupported capability (§6) | RFC process |
For anything listed in §6 "What is not supported yet," do not build production workflows against it — open an issue or follow the RFC process instead.