---
title: "NovaFabric User Guide"
description: "This guide is the working reference for the shipped nova command surface: what each command does, how to invoke it, and when to reach for it."
canonical: https://novafabric.ai/docs/user-guide/
last_updated: 2026-10-25
source: https://github.com/MSKazemi/novafabric/blob/main/docs/user-guide.md
---

# NovaFabric User Guide

This guide is the working reference for the shipped `nova` command surface:
what each command does, how to invoke it, and when to reach for it. It picks up
where the [getting started guide](https://novafabric.ai/docs/getting-started/) leaves off — that guide
walks you from install to your first capsule; this one is what you return to
once you are capturing, replaying, diffing, and auditing real runs.

## What you will learn

- How to **capture** any command as a portable, schema-valid, secret-scanned
  [Run Capsule](https://novafabric.ai/docs/concepts/) — with no changes to your application code.
- How to **inspect and validate** a capsule, and how to gate CI on capsule
  integrity and on the secret scanner's findings.
- How to **replay** a captured run in each of the five honest modes
  (`forensic`, `semantic`, `exact`, `mocked`, and the experimental
  `intervention`) and **diff** two runs
  structurally as a regression gate.
- How to query the **lineage graph** — provenance, blast-radius, replay-chain,
  time-travel — and emit OpenLineage events to a data catalog.
- How to use the **trust layer** — redaction and signed Evidence Bundles an
  auditor can verify offline with only `sha256sum` and an ed25519 verifier.
- How to capture **non-Python clients** through the transparent MCP and API
  proxies, and how to manage assets through the local **Asset Registry**.
- Which capabilities are **shipped and work today**, and which are **planned**
  design intent that you should not depend on yet.

NovaFabric is local-first: every command below runs entirely inside your own
infrastructure — laptop to cluster, online or air-gapped, with no accounts and
no telemetry. The five primitives it is built on — **Asset Registry**,
**Run Capsule**, **Replay**, **Lineage**, and **Evidence Bundle** — map
directly onto the workflow areas in this guide.

The guide is organized by workflow area, not alphabetically. Use the table of
contents to jump to what you need.

> **Both `nova` and `novafabric` refer to the same binary.** All examples use `nova`.

> **Maturity.** Nearly all commands documented here ship today but carry
> `experimental` maturity: they work, but on-disk formats and interface details
> may change before the v1.0 schema freeze. Anything labeled **planned** or
> **future design** is documented intent only and is **not** implemented — do
> not build on it. See [ROADMAP.md](https://github.com/MSKazemi/novafabric/blob/main/ROADMAP.md) for sequencing, and
> [What shipped experimental in v0.59](#what-shipped-experimental-in-v059) for
> the newest command groups.

---

## Table of contents

1. [Capture](#capture)
   - [nova capture](#nova-capture)
   - [SDK decorator (in-process capture)](#sdk-decorator-in-process-capture)
   - [Runners](#runners)
   - [Customizing the URL registry](#customizing-the-url-registry)
2. [Inspect and validate](#inspect-and-validate)
   - [nova validate](#nova-validate)
   - [nova scan-secrets](#nova-scan-secrets)
   - [Searching run content (experimental)](#searching-run-content-experimental)
3. [Replay and diff](#replay-and-diff)
   - [nova replay](#nova-replay)
   - [nova diff](#nova-diff)
4. [Lineage graph](#lineage-graph)
   - [nova lineage provenance](#nova-lineage-provenance)
   - [nova lineage blast-radius](#nova-lineage-blast-radius)
   - [nova lineage replay-chain](#nova-lineage-replay-chain)
   - [nova lineage time-travel](#nova-lineage-time-travel)
   - [nova lineage import](#nova-lineage-import)
   - [nova lineage emit-openlineage](#nova-lineage-emit-openlineage)
5. [Trust layer](#trust-layer)
   - [nova redact](#nova-redact)
   - [nova export-evidence](#nova-export-evidence)
   - [Cryptographic sealing (experimental)](#cryptographic-sealing-experimental)
6. [Non-Python client capture](#non-python-client-capture)
   - [nova mcp-proxy (experimental)](#nova-mcp-proxy-experimental)
   - [nova api-proxy (experimental)](#nova-api-proxy-experimental)
7. [Asset registry](#asset-registry)
   - [nova register](#nova-register)
   - [nova suggest-register](#nova-suggest-register)
   - [nova list](#nova-list)
   - [nova inspect](#nova-inspect)
   - [nova promote](#nova-promote)
   - [nova rollback](#nova-rollback)
   - [nova capture — asset status gate](#nova-capture--asset-status-gate)
   - [nova eval](#nova-eval)
   - [nova report](#nova-report)
8. [Local dashboard](#local-dashboard)
   - [nova serve (experimental)](#nova-serve-experimental)
9. [Environment variables](#environment-variables)
10. [What shipped experimental in v0.59](#what-shipped-experimental-in-v059)
11. [What shipped in v0.75–v0.94](#what-shipped-in-v075v094)
12. [Summary and next steps](#summary-and-next-steps)

---

## Capture

Capture is the entry point to everything else. It turns a command — a script, an
agent, an HPC training run, a notebook cell — into a **Run Capsule**: a
directory identified by a time-sortable ULID that holds every observable fact of
one execution. A capsule is the unit you later replay, diff, trace, and export.

### nova capture

The core capture command. Wraps any command and records its execution as a
replayable Run Capsule. No changes to your code are required.

```bash
nova capture python train.py --lr 0.001
nova capture python my_agent.py
nova capture -- python -c "import sys; sys.exit(1)"
```

Capsules are written to `$NOVAFABRIC_HOME/capsules/<ulid>/` by default
(`~/.novafabric/capsules/<ulid>/` when `NOVAFABRIC_HOME` is unset;
`$NOVAFABRIC_CAPSULE_DIR` overrides the base). Use `--output-dir` to change the
base directory for one run:

```bash
nova capture --output-dir /mnt/experiment-runs python train.py
```

Use `--` to separate `nova capture` flags from commands that contain their
own flags:

```bash
nova capture -- python script.py -v --config cfg.yaml
```

NovaFabric's exit code mirrors the wrapped command's exit code. A command that
exits 1 will cause `nova capture` to exit 1 — but **the capsule is still written
on failure**, with `status: failure`, an `error` block, and the full artifacts.
Capture never silently drops a run: success and failure are both evidence.

On success:

```
✓ Capsule written: /home/me/.novafabric/capsules/01HXAY7M5JZ8R7K4P9DPBYK2WX  (run_id=01HXAY7M5JZ8R7K4P9DPBYK2WX)
```

**What gets captured.** The following transports are hooked automatically via a
`sitecustomize.py` injected over `PYTHONPATH` — no SDK decorator, no import in
your code. If the library is not installed, its hook is silently skipped, and a
capsule is still written even when no AI SDK is present at all:

| Transport | What is recorded |
|---|---|
| `openai` SDK | `chat.completions.create` calls → `model-calls.jsonl`; since v0.105.0 also `responses.create`, async and streamed calls |
| `anthropic` SDK | `messages.create` calls → `model-calls.jsonl`; since v0.105.0 also async and streamed calls |
| `httpx` | Requests to URL-registry-classified hosts → `model-calls.jsonl` and `network_events.jsonl` |
| `httpx2` | Same as `httpx`, for the client `openai` 3.x uses by default; patched only when `httpx2` is installed (works today) |
| `requests` | Same classification as `httpx`; covers LangChain, LlamaIndex, `boto3` |
| `aiohttp` | Async wire-level capture; covers async LangChain, FastAPI agents |
| `urllib3` | Lowest-tier wire-level capture; covers `boto3`/Bedrock |
| `mcp` SDK | `ClientSession.call_tool` invocations → `tool-calls.jsonl` |
| Plugins | Any class in the `novafabric.hooks` entry-point group (experimental) |

The `requests` and `urllib3` hooks use a shared layering guard so a single
`requests.post()` produces exactly one record even though `requests` calls
`urllib3` internally.

**OTel GenAI semantic conventions.** Every model-call record follows the
[OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/).
All "Required when applicable" fields are extracted: `temperature`, `top_p`,
`top_k`, `max_tokens`, `stop_sequences`, `seed`, `frequency_penalty`,
`presence_penalty`, `response.id`, and `finish_reasons`. The `temperature`,
`top_p`, and `seed` fields are what make a later `exact` replay eligibility
check possible (see [nova replay](#nova-replay)).

**Export the run as portable OTel spans (experimental, NF-032/033).** Because those
semconv attributes are already stored, `--emit-otel-genai` maps the finished capsule
*outward* to OTel GenAI `gen_ai.*` spans (a root `invoke_agent` span, a `chat` client
span per model call, an `execute_tool` span per tool call) written to
`<capsule>/otel-genai-spans.json`. Message content is **off by default** (ADR-0021); add
`--capture-content` to include request messages, routed through the same secret-redaction
gate as capture and size-bounded. A model/tool call that recorded a `log_level`
(ADR-0127) also carries its OTel `SeverityNumber` projection as
`novafabric.severity_number` / `novafabric.severity_text` (`debug`→5, `info`→9, `warn`→13,
`error`→17; **experimental**); a call without a level gets neither attribute — no severity is
fabricated. The OTLP ingest endpoint (`POST /api/otlp/v1/traces`) reads the pair back
(**experimental**, ADR-0127 P4 inbound): an exported span re-imported into a capsule keeps
its `log_level` (recorded with `log_level_source: adapter`; an `ERROR` span status still
wins when more severe). A span event's OTel `SeverityNumber` is consumed the same way
(TRACE collapses to `debug`, FATAL to `error`); malformed values are ignored, never guessed.
Emit with:

```bash
nova capture --emit-otel-genai python my_agent.py
```

Standalone OTLP *logs* (`resourceLogs`) are ingested by `POST /api/otlp/v1/logs`
(**experimental**, ADR-0293; OTLP/JSON, or OTLP/protobuf with the `otlp` extra). They are
**not** written into any capsule — a sealed capsule is never amended — but appended to a
separate sidecar store, `$NOVAFABRIC_OTLP_LOG_DIR` (default `$NOVAFABRIC_HOME/otlp-logs`), one
JSONL stream per run (`novafabric.run_id` attribute), else per trace id, else per UTC day.
Each record notes whether its run's capsule was `sealed`, `unsealed` or `absent` when the
log arrived. The sidecar is correlation data, not sealed evidence. By default only
metadata is kept: times, severity (mapped to `log_level` exactly as above), ids,
`service.name`, attribute keys, and the body's type, length and SHA-256. Set
`NOVAFABRIC_OTLP_LOGS_STORE_BODY=1` to also keep body text and attribute values, redacted
with the capture secret rules and truncated. Bounds: 16 MiB per request, 10 000 records per
request, 64 MiB per stream file; records over the file cap come back as OTLP
`partialSuccess.rejectedLogRecords`.

Example (the ingest function the route calls, run against a temporary directory; a record
carrying a `novafabric.run_id` resource attribute lands in that run's stream, metadata only):

```text
runs/01RUNEXAMPLE00000000000001.jsonl
{"schema": "novafabric/otlp-log-record/v0", "run_id": "01RUNEXAMPLE00000000000001",
 "service_name": "agent-svc", "severity_number": 17, "log_level": "error",
 "time": "2026-09-21T14:13:20.000000Z", "attribute_keys": [],
 "body": {"type": "string", "bytes": 17, "sha256": "b84afc0d..."}, ...}
```

The body text itself is absent (only its length and SHA-256) until you set
`NOVAFABRIC_OTLP_LOGS_STORE_BODY=1`. Operator settings are in the
[operator guide](https://novafabric.ai/docs/operator-guide/#capture); the route is in the
[API reference](https://novafabric.ai/docs/api-reference/).

**Secrets are redacted before the capsule is finalized.** A secret scanner
rewrites matches in place as `[REDACTED:rule-id]` and writes
`redaction-proof.json` before the capsule closes. It scans every file in the
capsule: the call and event streams, `env.lock`, `assets.jsonl`,
`lineage.jsonl`, everything under `inputs/` and `outputs/`, and the manifest. A
final pass rescans the finished capsule before it is digested and sealed. The
rule pack matches known key formats (LLM providers, vector stores, AWS, GitHub,
NovaFabric's own keys); a secret in a format it does not know is not detected —
for example PEM private keys, JWTs, passwords and connection-string credentials.
The full list is in [what the scanner does not detect](https://novafabric.ai/docs/architecture/run-capsule/#what-the-scanner-detects-and-what-it-does-not),
so read a clean proof as "scanned", not "secret-free". A capsule that lacks this
proof is invalid to `nova validate` and cannot be exported as an Evidence
Bundle — redaction is a precondition of trust, not an afterthought.

---

### SDK decorator (in-process capture)

Use `@agent` when you own the entry point and want to capture without a
subprocess wrapper. The decorator installs capture hooks before calling the
function and removes them afterward — even if the function raises.

```python
from novafabric.sdk.agent import agent

@agent(name="research-agent", version="0.1.0", capsule_dir="capsules/")
def run():
    # openai, anthropic, httpx calls made here are auto-captured
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": "Hello"}],
    )
    return response

run()
```

The capsule structure is the same as `nova capture`'s, with one difference: the
decorator runs inside your process, so the capsule records `capture_mode:
sdk-decorator` and a label (`@agent:<name>@<version>`) instead of a command.
`nova replay --mode mocked` therefore refuses it (`CapsuleNotReplayable`, exit 1);
`forensic` and `semantic` replay, `nova diff` and `nova validate` work as usual.
`capsule_dir` is a capsule store, like `nova capture --output-dir`: every call
writes its own capsule to `capsules/<run_id>/`. Without `capsule_dir`,
the decorator emits OTel spans only — no capsule is written. This is the
original v0.1 observability mode and is still useful if you only need traces.

**Same finalization as `nova capture` (since v0.105.0).** The capsule goes
through the same secret scan, manifest redaction, residual pass, `lineage.jsonl`
and `evidence_digests`, and it is sealed when a signing profile exists
(`nova seal init`), so `nova verify` checks it like any other capsule. Sealing
stays opt-in. If finalization fails, your function's return value or exception is
unchanged: the capsule is left unsealed and `metadata.finalization_error` says why.
The same holds when an earlier capture step fails (for example, a full disk while
writing `env.lock`): the reason reads `capture incomplete: env.lock: OSError`.
The framework adapters behave the same way when the capsule cannot even be opened
(an unwritable `data_dir`): the call runs uncaptured and the failure is logged.

**Optional record-only tags (experimental, v0.59).** The decorator also accepts
`deployment_environment=` (ADR-0126), `variant=` (ADR-0116, A/B attribution),
and `session_id=`/`session_sequence=` (ADR-0122, multi-turn sessions). Each is
recorded verbatim as an additive optional manifest field, never inferred; the
matching CLI flags and `NOVAFABRIC_*` env vars take precedence. See
[Python API: SDK decorator](https://novafabric.ai/docs/python-api/#sdk-decorator).

See `examples/minimal-agent-run/agent.py` for a complete working example.

**When to use which.** Prefer `nova capture` for anything you can invoke as a
command — it needs zero code changes and works for non-Python subprocesses.
Reach for the `@agent` decorator only when you own the Python entry point and
want capture to live inside the process (for example, inside a long-running
service where spawning a subprocess is awkward).

---

### Runners

`nova capture` delegates subprocess execution to a **runner**. The runner
abstraction is what lets the same capture command target a laptop, a container,
a Kubernetes cluster, or a SLURM batch queue without changing your workload.
Choose one with `--runner`:

```bash
nova capture --runner local python agent.py    # default: runs on this machine
nova capture --runner docker \
  --runner-option image=myorg/agent-runtime:latest \
  python agent.py
nova capture --runner kubernetes \
  --runner-option image=myorg/agent-runtime:latest \
  --runner-option namespace=ml-jobs \
  python agent.py
nova capture --runner slurm \
  --runner-option partition=gpu \
  python train.py
```

> **One thing does change with the workload: its environment.** `--runner local`
> runs as you, on your machine, and keeps your full environment. Every other
> runner sends the workload somewhere else, so it receives **only** `NOVAFABRIC_*`
> variables — plus `PATH` for `slurm`, and anything you name explicitly in
> `extra_env` for `docker`, `kubernetes`, `slurm` and `lsf`:
>
> ```bash
> nova capture --runner kubernetes \
>   --runner-option image=myorg/agent-runtime:latest \
>   --runner-option namespace=ml-jobs \
>   --runner-option extra_env=LOG_LEVEL=debug \
>   python agent.py
> ```
>
> Credentials do not belong in `extra_env`. A value typed on the command line is in
> `nova capture`'s argv, readable with `ps` by every user on the host for the whole
> run; it is then stored in the Kubernetes `Job` object, in `docker inspect`, or with
> the SLURM/LSF job script (which `slurm` and `lsf` pipe to the scheduler on stdin,
> never on a command line). On Kubernetes use a `Secret` bound to the pod's
> `service_account`. See [`operator-guide.md`](https://novafabric.ai/docs/operator-guide/) §3.3–3.4,
> ADR-0270 and ADR-0311.

Pass runner-specific options with `--runner-option key=value` (repeatable). All
runners are non-privileged by design — see the enforcement notes per runner.

**What the capsule records about where the run happened** (experimental, since v0.105.0,
ADR-0307). `capsule.yaml` names the runner under `host.runner.name`. For `docker` and
`kubernetes` it also records the image you passed and the digest the runtime actually
resolved it to:

```yaml
host:
  runner:
    name: docker
    image:
      reference: python:3.12-slim
      image_id: sha256:…
      repo_digests: [docker.io/library/python@sha256:…]
      resolved_by: docker-image-inspect
```

When no digest can be resolved, `image` carries `unresolved_reason` instead; a digest is
never inferred from the tag. When `nova capture` runs inside a Slurm job, or you use
`--runner slurm`, `host.slurm` records the job id (plus array ids, partition, cluster and node
count when Slurm sets them). Node names are recorded only as a hash, the same way the hostname
is. The environment lock still describes the machine that ran `nova`, not the container.
Every field, and what is not recorded:
[run-capsule.md](https://novafabric.ai/docs/architecture/run-capsule/#hostrunner-and-hostslurm-experimental-adr-0307).

---

### Capturing a framework agent without `nova capture`

A runner captures a *command*. If your agent is a framework object inside a
Python process, wrap it instead — each invocation writes its own Run Capsule,
and the wire-level hooks record the model calls the framework makes.

```python
from novafabric.adapters import wrap_llamaindex, wrap_pydantic_ai, wrap_haystack

engine = wrap_llamaindex(index.as_query_engine())   # LlamaIndex
agent  = wrap_pydantic_ai(agent, run_name="support-bot")  # Pydantic AI
pipe   = wrap_haystack(pipe, run_name="rag-qa")     # Haystack
```

Each wrapper patches the entry point **in place** and returns the same object, so
existing references keep working (LangGraph's `wrap_langgraph` is the exception: it
returns a wrapper, so use the returned object). Streaming calls are captured too
(experimental): LangGraph `stream` / `astream`, LlamaIndex `stream_chat` / `astream_chat`
and `streaming=True` query engines, Pydantic AI `run_stream` / `iter`. The capsule stays open until the stream ends and records
`status: partial` (with `metadata.partial_reason`) when a stream is abandoned or cancelled
before it finishes — see [`cli-reference.md`](https://novafabric.ai/docs/cli-reference/) §Framework Adapters for
exactly what each case records. Install the framework itself (`pip install
llama-index` / `pydantic-ai` / `haystack-ai`) — these are not NovaFabric extras,
and the `wrap_*` call raises `ImportError` naming the command if the framework is
absent.

Adapters ship for eleven frameworks. Each is importable by name from
`novafabric.adapters`:

| Framework | Entry point | Wraps |
|---|---|---|
| LangGraph | `wrap_langgraph(graph)` | a compiled graph |
| AutoGen | `wrap_autogen(agent)` | an agent (`initiate_chat`, `a_initiate_chat`) |
| CrewAI | `wrap_crewai(crew)` | a crew (`kickoff`, `kickoff_async`) |
| DSPy | `wrap_dspy(program)` | a program (`forward` / `__call__`, `aforward` / `acall`) |
| LlamaIndex | `wrap_llamaindex(engine)` | a query/chat engine or agent |
| Pydantic AI | `wrap_pydantic_ai(agent)` | an `Agent` |
| Haystack | `wrap_haystack(pipeline)` | a `Pipeline` |
| OpenAI Agents SDK | `register_openai_agents()` | registers a trace processor |
| Google ADK | `make_google_adk_plugin()` | returns a plugin |
| AWS Bedrock AgentCore | `wrap_bedrock_agentcore(client)` | a boto3 `bedrock-agent-runtime` client (returns a wrapped client) |
| A2A SDK | `make_a2a_interceptor()` | returns an interceptor |

The OpenAI Agents SDK, Google ADK and A2A entry points are not wrappers — they hook the framework's own extension point
(ADR-0078) rather than patching a method. A wrapper patches the listed methods on the object you pass and returns the
same object; the patched methods take exactly the arguments the framework's do, positional ones included, and each
call (sync or async) is one capsule — a captured call that makes another, such as `kickoff_async` running `kickoff`,
records into the outer capsule.

**Evidence and sealing (since v0.105.0).** Every adapter finalizes its
capsule through the same path as `nova capture`: secret scan, manifest redaction,
residual pass, `lineage.jsonl`, `evidence_digests`, and a seal when a signing
profile exists (opt-in), so `nova verify` works on an adapter capsule. Configured
maskers (`nova capture --masker`) do not run in-process. A finalization failure
never breaks the framework call: the capsule is left unsealed and
`metadata.finalization_error` records why.

**Where adapter capsules land, and how to refer to them.** An adapter writes to the
`data_dir` you pass it; with none, to `$NOVAFABRIC_HOME/runs/<run-id>/` when
`NOVAFABRIC_HOME` is set, otherwise to `./.novafabric/runs/<run-id>/` under the
working directory. That is **not** where `nova capture` writes
(`$NOVAFABRIC_CAPSULE_DIR`, default `~/.novafabric/capsules/`). A capsule path
always works (`nova replay .novafabric/runs/<run-id>/ --mode forensic`). Since v0.105.0
(v0.104.0 resolved a bare id in the capture store only) the bare run id
works too: `nova replay`, `nova diff`, `nova validate` and every other command that
takes a run id also look in those two adapter defaults, print a `note:` on stderr
naming the directory the capsule was found in, and fail with an error listing both
paths if the same id exists in two of them. Passing `--capsule-dir` to a command
that has it searches only that directory.

**Replaying an adapter capsule.** An adapter capsule is captured inside a framework
call, so it records `capture_mode: sdk-decorator` and a `@framework:name` label, not a
command. `nova replay --mode forensic` and `--mode semantic` work on it; `--mode mocked`
refuses it up front (`CapsuleNotReplayable`, exit 1) because there is nothing to
re-run; `--mode exact` reports it not eligible. Re-running a framework agent from its
capsule is **future design** (ADR-0306, open question 7). To get a mocked-replayable
capsule, capture the script that runs the agent with `nova capture -- python agent.py`. Full reference, including each
adapter's entry-point and capture-mode details, is in
[`cli-reference.md`](https://novafabric.ai/docs/cli-reference/) §Framework Adapters.

**Google ADK tools in a mocked replay (experimental, since v0.106.0).**
For an ADK agent captured with `nova capture -- python agent.py`, add
`novafabric.adapters.google_adk.make_tool_plugin()` as the **first** plugin of the
`Runner`. Under capture each ADK tool call is recorded (payloads at
`NOVA_CAPTURE_LEVEL=forensic`); under `nova replay` the recorded result is returned
through ADK's documented `before_tool_callback` short-circuit and the tool body never
runs. Unmatched or unservable calls are refused (fail closed), `--permissive` and
`replay.yaml` `tool_overrides` work as for MCP and `record.tool`, and the mutation
class that gates them is declared in `make_tool_plugin({...})`. It does not make
capsules from `make_google_adk_plugin()` replayable — those keep a pseudo-command
(ADR-0306 open question 7). Details: [`cli-reference.md`](https://novafabric.ai/docs/cli-reference/) §Google
ADK adapter and [`architecture/replay-modes.md`](https://novafabric.ai/docs/architecture/replay-modes/).

**OpenAI Agents SDK tools in a mocked replay (experimental, since v0.106.0).**
For an Agents SDK workload captured with `nova capture -- python agent.py`, pass the
agent's tools through `novafabric.adapters.openai_agents.wrap_tools([...], {"tool": "read-only"})`.
Under capture each function-tool call is recorded (payloads at
`NOVA_CAPTURE_LEVEL=forensic`); under `nova replay` the recorded result is returned and the
tool body never runs. Unmatched or unservable calls are refused (fail closed), and the
mutation class that gates `--permissive` and `replay.yaml` `allow: true` is declared in
`wrap_tools`. **Limits:** the Agents SDK has no documented way to skip a tool, so this
replaces `FunctionTool.on_invoke_tool` — an undocumented seam, checked against
`openai-agents` 0.17.x only; tools you do not pass through `wrap_tools` still run live; and
capsules from `register()` stay non-replayable (pseudo-command). Details:
[`cli-reference.md`](https://novafabric.ai/docs/cli-reference/) §Agents SDK tool seam.

**`local` (default).** Runs the workload as a local subprocess. Equivalent to
the pre-v0.6 behavior. No additional setup required.

**`docker`.** Runs the workload inside a container. Required option: `image`.
The image must already have NovaFabric installed. The capsule directory is
mounted as a volume at `/novafabric/capsule`; artifacts land on the host
filesystem.

```bash
nova capture --runner docker \
  --runner-option image=myorg/agent-runtime:abc1234 \
  --runner-option network=bridge \
  --runner-option user=1000:1000 \
  python my_agent.py
```

Optional options: `network`, `workdir`, `user`, `extra_volumes`, `extra_env`.
The runner enforces no `--privileged`, no docker-socket mount, no host
namespace defaults.

**`kubernetes`.** Runs the workload as a Kubernetes `Job` via `kubectl`. Required
options: `image`, `namespace`. Optional: `service_account`, `node_selector`,
`resources`. Capsule artifacts are pulled back via `kubectl cp` after completion.
The job manifest enforces `privileged: false`, `hostNetwork: false`,
`backoffLimit: 0` — no root, no host namespace, no silent retries. A failed status
poll (an API-server blip, a throttled `kubectl`, an expired token) is retried until the
timeout; the count and last error are recorded as `runner_metadata.poll_errors` and
`last_poll_error`. Releases up to v0.107.0 treated one failed poll as the end: they
deleted the still-running Job and reported a timeout.

**`slurm`.** Runs the workload as a SLURM batch job via `sbatch`. Required option:
`partition`. Optional: `account`, `qos`, `time`, `nodes`, `gres`, `mem`,
`constraint`. The capsule directory **must be on a shared filesystem** (NFS,
Lustre, GPFS) visible from every compute node — the runner trusts the shared FS
rather than rsyncing artifacts. This is what makes the shared-filesystem
registry usable as a laptop↔cluster handoff protocol.

```bash
export NOVAFABRIC_SLURM_SHARED_DIR=/home/vagrant  # shared path
nova capture --runner slurm \
  --runner-option partition=gpu \
  --output-dir $NOVAFABRIC_SLURM_SHARED_DIR/runs \
  python train.py
```

> **Prototype.** Multi-node distributed runs recorded as a **parent/child
> capsule tree** under a single `global_run_id` shipped as a prototype in
> v0.10+ (ADR-0039; `nova run new-run-id / validate-distributed / show /
> lineage` — see the [CLI reference](https://novafabric.ai/docs/cli-reference/)). It is implemented and
> tested but not yet validated at target scale. Each single-node `nova capture`
> still produces one capsule with one writer; a one-node run is the degenerate
> case of the distributed model.

---

### Customizing the URL registry

The wire-level hooks (`httpx`, `requests`, `aiohttp`, `urllib3`) classify
outbound URLs against a vendored YAML registry. Default coverage includes
OpenAI, Anthropic, Cohere, Together, Mistral, Replicate, AWS Bedrock, and
Ollama on `localhost:11434`.

To add a private endpoint or a provider not in the default list, create
`~/.novafabric/url_registry.yaml`. A user-override file replaces the
vendored default entirely — copy any entries you still want from the default
before editing.

```yaml
schema_version: "0.1.0"
patterns:
  - match: "internal-llm.example.com"
    gen_ai_system: "internal-prod"
    transport: "http"
  - match: "api.openai.com"
    gen_ai_system: "openai"
    transport: "http"
  - match: "api.anthropic.com"
    gen_ai_system: "anthropic"
    transport: "http"
```

To override per-invocation:

```bash
NOVAFABRIC_URL_REGISTRY=/path/to/registry.yaml nova capture python agent.py
```

Because the registry is a local, editable file and never hardcodes a hosted
endpoint, you can point every classified call at a private mirror or proxy —
NovaFabric requires no connection to any NovaFabric-operated service.

---

## Inspect and validate

Once you have a capsule, the first thing to do is confirm it is well-formed and
free of leaked secrets. Both checks are CI-friendly and exit non-zero on
failure, so you can wire them into a pipeline.

### nova validate

Validates a capsule directory, an asset YAML spec, or a replay result directory
against their JSON schemas. The command routes automatically based on what
`path` contains.

**Capsule validation:**

```bash
nova validate ~/.novafabric/capsules/01HXAY7M5JZ8R7K4P9DPBYK2WX/
# ✓ Valid capsule: 01HXAY7M5JZ8R7K4P9DPBYK2WX  status=success
```

Checks `capsule.yaml`, `env.lock`, `redaction-proof.json`, `lineage.jsonl`
(if present), and the presence of all required files and directories.

**Asset spec validation** (see [Asset registry](#asset-registry)):

```bash
nova validate my-model.yaml
# ✓ Valid model spec: my-model@1.0.0
```

**Replay result validation:**

```bash
nova validate ~/.novafabric/replays/01HXB.../
# ✓ Valid replay result: 01HXB...  mode=forensic  status=success
```

**Use in CI to gate on capsule integrity:**

```bash
nova capture python agent.py
nova validate ~/.novafabric/capsules/$(ls -t ~/.novafabric/capsules/ | head -1)
```

---

### nova scan-secrets

Read-only inspection of a capsule's `redaction-proof.json`. Reports what the
secret scanner found during capture. Does not modify the capsule — to re-run the
scanner with different strategies, use [nova redact](#nova-redact).

```bash
nova scan-secrets ~/.novafabric/capsules/01HX.../
```

Clean output:

```
✓ 01HXAY7M5JZ8R7K4P9DPBYK2WX: no findings
```

With findings:

```
! 01HXAY7M5JZ8R7K4P9DPBYK2WX: 2 findings (critical=1, high=1, medium=0, low=0)
rule_id          severity  target           strategy
openai-api-key   critical  model-calls.jsonl  mask
pii-email        high      outputs/stdout.txt  mask
```

Use `--fail-on <severity>` to gate CI on severity level:

```bash
nova scan-secrets <capsule> --fail-on critical    # exits 2 on critical findings
nova scan-secrets <capsule> --fail-on high        # exits 2 on critical or high
```

Exit codes: `0` (clean or below threshold), `1` (missing proof / bad input),
`2` (threshold exceeded).

Use `--json` to emit the full `redaction-proof.json` as parseable JSON to
stdout — useful for piping into `jq` or a CI reporting tool.

### Searching run content (experimental)

**Status: experimental** (ADR-0204, first slice). `nova search` finds runs by
what the agent actually said or did — not just by run id or command:

```bash
nova search "invoice INV-2291"        # which run mentioned this invoice?
nova search "rm -rf"                  # where did an agent run this? (literal)
nova search "429" --status failure    # failed runs that saw a rate limit
nova search --reindex                 # one-time backfill of older capsules
```

The index lives in the local registry DB (SQLite FTS5, zero new
dependencies) and only ever contains **post-redaction** capsule text — the
same four files the secret scanner redacts before a capsule is complete.
New capsules are indexed automatically at ingest (`nova serve` /
`nova ingest-capsule`); run `nova search --reindex` once to backfill
capsules captured before this feature. A capsule missing one of those files
simply contributes fewer (or no) content rows; the `minimal` capture level does
not drop any of them today (it is planned, not implemented — see
[`nova policy capture-level`](https://novafabric.ai/docs/cli-reference/evidence-fabric/#nova-policy-capture-level-set)). All query terms
must match (AND); a trailing `*` does prefix search; FTS5 operators like
`OR`/`NEAR`/`-` are matched literally. The same search is available in the
dashboard API via `GET /api/runs/search?scope=content`. Set
`NOVA_CONTENT_INDEX=off` to disable indexing. Postgres server-mode parity
is **planned**; indexing the extended event streams is **planned** (ADR-0204
P2 — the ADR-0209 scanner-coverage prerequisite has shipped; the corpus
stays pinned to the four v0 streams until the P2 slice bumps it). See the
[CLI reference](https://novafabric.ai/docs/cli-reference/query-search-drift/#nova-search-experimental-adr-0204).

---

## Replay and diff

This is the core of "replayable AI infrastructure." Tracing tells you what
happened; replay tells you whether a past run can be re-executed under controlled
conditions, and diff tells you exactly what changed between two runs. Every
replay writes a `replay_result.yaml` report; only an `intervention` replay also
writes a new (counterfactual) capsule that you can diff against its original.

### nova replay

Replay a captured run from its capsule, in one of **five honest, falsifiable
modes** (the fifth, `intervention`, is experimental and described in
[Concepts](https://novafabric.ai/docs/concepts/#intervention-mode-experimental)). NovaFabric deliberately does **not** claim byte-exact replay of remote
LLM calls; the mode you choose reflects how much determinism the run actually
supports.

**Forensic mode (recommended as a first step):**

```bash
nova replay ~/.novafabric/capsules/01HX.../ --mode forensic
```

No subprocess is launched, no network calls are made. NovaFabric reads the
manifest, traces, and model calls directly from the capsule. Safe to run in
any context — including on capsules received from colleagues or downloaded from
an artifact store. This is the mode for audit and post-incident review.

**Semantic mode** (read-only similarity analysis):

```bash
nova replay ~/.novafabric/capsules/01HX.../ --mode semantic
```

Computes pairwise text similarity across model call responses using
`difflib.SequenceMatcher`. Returns a `similarity_score` (0.0–1.0). No
subprocess, no network. Useful for checking whether a run's responses are
internally consistent or vary significantly across calls — the pragmatic answer
when the upstream LLM is remote and non-deterministic.

**Exact mode** (eligibility check):

```bash
nova replay ~/.novafabric/capsules/01HX.../ --mode exact
```

Checks whether the capsule meets the requirements for byte-exact replay:
`env.lock` `mode: deterministic` (the legacy `lock_mode` key is still read), a
`gen_ai.request.seed` on every model call, no tool-schema drift, and a command
to re-run (a framework-adapter capsule is never eligible).
Returns `exact_eligible` (bool) and a list of `exact_reasons` if not eligible.
No subprocess, no network. Remote LLMs are almost never exact-eligible; this
mode is for local / on-prem / compliance runs where determinism is controllable.

**Mocked mode:**

```bash
nova replay ~/.novafabric/capsules/01HX.../ --mode mocked
```

The original command is re-spawned as a subprocess (Python workloads; what
v0.104.0 served versus v0.105.0 is listed under
[Release scope](https://novafabric.ai/docs/architecture/replay-modes/)). A capsule
that records no command — written by a framework adapter or `@agent`
(`capture_mode: sdk-decorator`), or imported from OpenTelemetry spans — is refused
before anything is spawned (`CapsuleNotReplayable`, exit 1, also under `--dry-run`).
Recorded
responses are served, in recorded order, for OpenAI `chat.completions.create`,
OpenAI `responses.create` and Anthropic `messages.create` calls — sync or async,
streamed or not (ADR-0304) — and recorded results for **MCP**
`ClientSession.call_tool` calls (one recorded result per call). **Every other
tool runs live** — HTTP requests, shell commands, file writes, framework-native
tools, functions you did not declare — so run replays of such agents in a sandbox
or against test credentials.

**Declaring your own Python tools** (experimental, since v0.105.0 — ADR-0306 slice 1).
Decorate a tool function with `record.tool` and a mocked replay returns its
recorded result instead of running it:

```python
from novafabric.capture import record

@record.tool(mutation_class="external-side-effect", ignore=("ctx",))
def send_invoice(ctx, customer_id: str, amount_cents: int) -> dict: ...
```

```bash
NOVA_CAPTURE_LEVEL=forensic nova capture python agent.py   # keeps arguments and results
nova replay <run-id>                                       # send_invoice's body does not run
```

The arguments and result are stored only at the `forensic` (or `air_gapped`)
capture level; at the default level the capsule holds digests and the replay
fails with a "re-capture with payload capture enabled" reason. Those digests are
taken after secret redaction, but a secret the rules miss (a short password, a PIN)
still feeds them, so do not decorate tools that take credentials at the default
level. Results must be
JSON-native (no tuples or objects) and at most 1 MiB. See
[Python API: `record.tool`](https://novafabric.ai/docs/python-api/#extended-event-recording-experimental).
The result lists the outbound connections the replay made
(`replay_contract.network_connections_live`); they are reported, not blocked.
Since v0.105.0 (experimental), a recorded call that failed (a rate limit, a 4xx, a 5xx
after the SDK's retries, a timeout) is replayed by raising the same SDK exception class at the same
position, so the workload's error handling runs again.

The replay is **fail-closed** (ADR-0300): an extra model call, a call on an
unsupported surface (`parse`, legacy completions, `with_raw_response`), an MCP or
`record.tool` call with no recorded result (the live tool is not run), a
`record.tool` call whose record cannot be served (`tool_result_not_servable`), or a
recorded response that is never requested makes the replay `failure` with a
`divergence_reason`. `--permissive` keeps the older warn-and-continue behaviour and
only reports — except that an unmatched MCP or `record.tool` call runs live only if
a safety-ladder flag permits its `mutation_class` (the decorator's declared class;
every MCP call counts as `unknown`, so it needs `--allow-unknown-mutation`).
*Changed in v0.105.0 (ADR-0306):* `--permissive` alone used to run every unmatched
MCP call live.

The safety-ladder flags classify the capsule's recorded tool calls for the
`--dry-run` report, and any write-permitting rung (`--allow-mutating`,
`--allow-external-side-effects` or `--allow-unknown-mutation`) adds an audited
`replay_mutating` policy gate before the replay starts; a deny refuses the replay. Inside the replayed process they gate only the intercepted tool
surfaces (MCP, `record.tool` and, experimental, Google ADK tools through the tool
plugin and OpenAI Agents tools through `wrap_tools`) — an unmatched call under `--permissive`, and a `replay.yaml`
`allow: true` override — and never stop a tool replay does not intercept:

```bash
nova replay ~/.novafabric/capsules/01HX.../ --mode mocked \
  --allow-readonly             # rung: read-only tool calls
  --allow-mutating             # rung: idempotent / non-idempotent writes (+ policy gate)
  --allow-external-side-effects  # rung: external-side-effect tools (+ policy gate)
  --allow-unknown-mutation     # rung: unclassified tools (+ policy gate)
```

**Per-tool overrides** (experimental, since v0.105.0, ADR-0306). A capsule's
`replay.yaml` may carry `tool_overrides`; in `mocked` mode they are enforced inside
the replayed process, and `--dry-run` prints the same decisions:

```yaml
tool_overrides:
  - tool_name: send_email
    allow: false   # never run live, even under --permissive
  - tool_name: lookup_order
    allow: true    # re-execute -- only if you also pass the ladder flag for its class
```

`replay.yaml` travels with the capsule, so its restrictions hold and its
permissions need your consent: `allow: true` without the matching `--allow-*` flag
serves the recorded result and reports `override_not_honoured`. An `allow: false`
on a tool recorded on a surface replay cannot intercept (HTTP, shell, an undeclared
function) cannot be enforced: a strict replay refuses to start
(`ToolOverrideUnenforceable`, exit 3), and `--permissive` runs it and reports
`override_unenforceable`. Each override's outcome is listed under
`replay_contract.tool_overrides` in `replay_result.yaml`.

This is the mode for CI and regression testing: re-run the recorded command
against its recorded responses and diff the result.

**Dry-run** (see what would happen without running):

```bash
nova replay ~/.novafabric/capsules/01HX.../ --dry-run
```

Results land in `$NOVAFABRIC_HOME/replays/<replay-ulid>/replay_result.yaml`. Use
`--output-dir` to change the base directory.

**Mode comparison:**

| Mode | Re-executes command | LLM calls | Tool calls | Output | Primary use |
|---|---|---|---|---|---|
| `forensic` | No | From capsule (read-only) | Not re-executed | Inspection report | Audit / post-incident |
| `semantic` | No | Read-only analysis | Not re-executed | `similarity_score` (0–1.0) | Drift detection |
| `exact` | No | Read-only analysis | Not re-executed | `exact_eligible` + `exact_reasons[]` | Determinism / compliance check |
| `mocked` | Yes (refused for a capsule with no command) | OpenAI Chat Completions / Responses API and Anthropic Messages, sync or async, streamed or not: from the capsule, recorded errors raised again; unsupported surfaces refused | MCP `call_tool`: from the capsule or refused; other tools **live** | Replay result + divergence report | CI / regression |
| `intervention` (experimental) | Yes, under mocked semantics | From the capsule, with one substituted event | Other tools **live**; a substituted tool result is not delivered | Replay result + counterfactual capsule | Counterfactual root cause |

---

### nova diff

Structurally compare two Run Capsules. Aligned field-by-field: model calls
(a span id unique on both sides, then sequence position anchored on identical
requests), tool calls (by tool name + argument hash, then position), environment,
and every file under `outputs/`, nested ones included (by capsule-relative path
and content hash). This is what turns "it worked yesterday, fails
today" into a precise, mechanical answer.

```bash
nova diff ~/.novafabric/capsules/01HX.../ ~/.novafabric/capsules/01HY.../
```

Sample output:

```
Diff: 01HX... → 01HY...
  changed=1  added=0  removed=0
Outputs:
  ~ outputs/stdout.txt
```

**Use as a CI gate.** `--assert-no-regressions` exits 1 if any changes are
detected — a changed, added or removed entry in any section — and, since v0.105.0
(ADR-0303), exits 2 when
the comparison could not be made (a capsule ref that does not resolve, a
`capsule.yaml` or `env.lock` that cannot be read or parsed, or a record line
that could not be read, which is counted in `skipped_malformed_lines` and warned
about on stderr). Since v0.105.0 a model call whose recorded request parameters
(temperature, seed, …) changed also counts as a change (ADR-0303 Amendment 2). Wire it into CI to catch
behavioral regressions before they reach production:

```bash
nova diff cap-a/ cap-b/ --assert-no-regressions
```

**Output formats:**

```bash
nova diff cap-a/ cap-b/ --output-format json               # machine-readable
nova diff cap-a/ cap-b/ --output-format github-annotation  # for PR checks
```

`github-annotation` emits each difference as an `error` line — an added or
removed call included — and a single `notice` when there is none: an `error`
appears exactly when `--assert-no-regressions` would exit 1.

**Asset spec diff.** If both arguments contain `@`, the command falls through
to the asset registry diff (see [nova inspect](#nova-inspect)):

```bash
nova diff my-model@1.0.0 my-model@1.1.0
```

**Group by recorded A/B variant (ADR-0116).** `--group-by variant` labels the
diff as cross-arm or within-arm using each capsule's recorded
`(experiment_id, variant_id)` — read-only, capsule paths only, text/json output
only:

```bash
nova diff --group-by variant runs/arm-a/ runs/arm-b/
```

**Statistical regression gate (experimental, NF-007).** `--significance` runs a
Wald SPRT test over stored boolean-outcome scores instead of a structural diff —
a distinct mode with no positional capsule refs. It reports Wilson confidence
intervals for both sides and exits non-zero on a statistically significant
regression:

```bash
nova diff --significance --baseline base/ --candidate cand/ --metric task_pass
```

**A common pattern** is capture → replay → diff: capture a baseline; after a code
change, replay it in `mocked` mode (which fails closed if the new code makes a
model or MCP call the recording does not hold), then capture the changed run and
diff that capture against the baseline under `--assert-no-regressions`. A
`mocked` replay writes a report, not a capsule, so it is not itself a diff
input; an `intervention` replay's counterfactual capsule is.

---

## Lineage graph

Replay and diff answer questions about individual runs; lineage answers
questions *across* runs — what a run consumed, what it produced, and what would
be affected if an upstream artifact changed.

Every `nova capture` run automatically writes `lineage.jsonl` into the capsule.
Three edge types are inferred:

| Edge type | Source | Meaning |
|---|---|---|
| `consumed` | `assets.jsonl` entries | The run used this asset |
| `produced_by` | Output files in `capsule.yaml` | This artifact was produced by the run |
| `replayed_from` | Replay metadata | This run is a replay of another |

The edges are also indexed into a local SQLite graph at
`~/.novafabric/registry.db` (same file as the asset registry). **The database
is a rebuildable cache** — if you lose it, run `nova lineage import <runs-dir>`.
The `lineage.jsonl` files inside the capsules are the source of truth; the graph
is a derived index.

---

### nova lineage provenance

What did this run (or asset, or artifact) depend on? Walks ancestors.

```bash
nova lineage provenance <run-id>
nova lineage provenance registry:my-dataset@1.0.0
nova lineage provenance <run-id> --depth 3
nova lineage provenance <run-id> --output json
```

Options:
- `--depth N` — maximum traversal depth (default: 5)
- `--kind run|asset|artifact` — filter to a specific node kind
- `--output text|json` — output format (default: text)

---

### nova lineage blast-radius

If this asset or artifact changes, which runs would need to be re-evaluated?
Walks descendants — the inverse of provenance.

```bash
nova lineage blast-radius local:datasets/training-set@1.0.0
nova lineage blast-radius <run-id> --depth 2
```

This is the primary "impact analysis" query. Run it before updating a shared
dataset to see what downstream runs depend on it, or during incident response to
scope what a bad artifact touched.

Options: `--depth`, `--kind`, `--output` (same as `provenance`).

---

### nova lineage replay-chain

Show the replay ancestry of a run, back to the original captured run.

```bash
nova lineage replay-chain <replay-run-id>
```

Useful when you have several generations of replays and want to trace which
original capture they all stem from.

Options: `--output text|json`.

---

### nova lineage time-travel

Show the lineage state of a node as of a specific timestamp. Useful for
reconstructing what was known at a point in time, for audits or incident reviews
("what did the dependency graph look like on the day of the incident?").

```bash
nova lineage time-travel <run-id> --asof 2026-05-01T00:00:00Z
nova lineage time-travel registry:my-dataset@1.0.0 --asof 2026-04-15T12:00:00Z
```

Options: `--kind`, `--output` (same as `provenance`).

---

### nova lineage import

(Re-)index lineage from capsule directories into the local SQLite graph. Use
this to backfill runs captured before the lineage graph was set up, or to
rebuild the index after moving the database.

```bash
nova lineage import ~/.novafabric/capsules/01HX.../    # single capsule
nova lineage import ~/.novafabric/capsules/            # all capsules in the directory
```

The graph is fully derivable from `lineage.jsonl` files in the capsule
directories — losing the SQLite index is not data loss.

> **Scale note.** The SQLite lineage graph is the local-mode default and is
> designed for graphs below roughly 1M edges. A KuzuDB-backed v2 tier for
> larger graphs shipped as **experimental** in v0.10+ (`nova lineage-store
> migrate` / `profile`; see the [lineage migration guide](https://novafabric.ai/docs/lineage/migration-guide/)).
> Postgres (recursive CTE), Apache AGE (openCypher) and JanusGraph (Gremlin)
> backends also ship as **experimental** library classes in
> `novafabric.lineage.backends` (no CLI flag selects them yet). Cross-site lineage
> federation remains **future design** (a protocol prototype lives in
> `novafabric.lineage.federation`; nothing in the CLI or server uses it).

---

### nova lineage emit-openlineage

Emit capsule runs as [OpenLineage](https://openlineage.io) 2.0.2 events
(START / COMPLETE / FAIL). Enables integration with data catalog tools such as
Marquez, Atlan, and OpenMetadata.

```bash
# Print to stdout
nova lineage emit-openlineage ~/.novafabric/capsules/01HX.../ --output -

# Post to an HTTP endpoint
nova lineage emit-openlineage ~/.novafabric/capsules/ --output http://marquez:5000/api/v1/lineage

# Write to a file
nova lineage emit-openlineage ~/.novafabric/capsules/ --output events.jsonl
```

If `--output` is omitted, the target is resolved from `OPENLINEAGE_URL` →
`OPENLINEAGE_FILE` → stdout.

You can also trigger automatic emission at capture time by setting
`OPENLINEAGE_URL` before running `nova capture` — the orchestrator will post
events without an explicit `emit-openlineage` call.

**Custom run facets (experimental, NF-036/037).** Add `--with-facets` to attach
NovaFabric's custom facets to the COMPLETE event — `novafabric_capsule` (capsule/run id
and hash), `novafabric_eval` (verdict + suite + metrics), `novafabric_policy` (promotion
gate decision), and the standard `executionParameters` facet. Add `--otel-correlation`
(implies `--with-facets`) to also attach `novafabric_otel_correlation` (`trace_id` /
`span_id`) so a lineage node links to its OTel GenAI spans. Facets are additive and
schema-validated before emission, so a consumer that ignores them still sees unchanged
core OpenLineage events:

```bash
nova lineage emit-openlineage ~/.novafabric/capsules/01HX.../ --with-facets --otel-correlation
```

> **Note:** these OpenLineage run facets are a different thing from the capsule
> `facets` container (ADR-0196, experimental) — the additive `facets.<domain>`
> evidence objects that record-only modules such as `settlement`, `embodied`, and
> `science` attach to a capsule. Same word, two unrelated places; see
> [Evidence facets](https://novafabric.ai/docs/concepts/#evidence-facets-experimental) in Concepts.

---

### Graph analytics (experimental — ADRs 0212–0215)

Beyond traversal, NovaFabric can turn the whole lineage graph into insight —
all read-only, local-first, and deterministic:

```bash
# Structurally critical nodes: hubs + single points of failure
nova lineage metrics --top 10

# Rank the upstream root-cause suspects for a failed run
nova lineage root-cause 01HX...

# Export the graph for Gephi / yEd / Neo4j
nova lineage export-graph --format cypher -o lineage.cypher

# One synthesized report (hubs, communities, orphans, health, cost)
nova insights --output markdown -o insights.md
```

`metrics` ranks by degree / PageRank / betweenness and flags articulation
points; `root-cause` combines error signals, recency, edge confidence, and
cross-run failure correlation and never fabricates a culprit when there is no
signal; `export-graph` emits byte-stable GraphML/GEXF/Cypher; `insights`
composes all of it plus seeded Louvain communities and best-effort cost, and
reports any unavailable data source as unavailable rather than inventing it.
See the [CLI reference](https://novafabric.ai/docs/cli-reference/lineage/#nova-lineage-metrics) for full flags.

### nova lineage consume (experimental)

Runs the JetStream `LineageConsumer` as a foreground daemon: it pulls
NovaFabric lineage events off a NATS subject and bulk-COPYs derived edges into
a KuzuDB graph on a size-or-time flush trigger. This is the cluster-scale
ingestion path alongside the local `nova lineage import` — local-mode capture
never requires it; use it only when events already flow through a NATS
deployment rather than sitting in capsule directories on a shared filesystem.
Requires `pip install 'novafabric[scale,scale-kg]'` (nats-py + kuzu) and a
running NATS JetStream server.

```bash
nova lineage consume --nats-url nats://nats:4222 --kuzu-path .nova/kg/lineage.kuzu
```

Options include `--subject` (default `novafabric.lineage.>`), `--batch-size`,
`--fetch-timeout`, `--flush-batch-size`, and `--flush-interval-s` (max seconds
between flushes even below the batch-size threshold). **Not exactly-once:** a
NATS message is acked once its edges are extracted, before the flush actually
persists them — see the
[CLI reference](https://novafabric.ai/docs/cli-reference/lineage/#nova-lineage-consume-experimental-cluster-scale)
for the full flag list and delivery-semantics detail.

## Trust layer

The trust layer is what makes a capsule shareable and auditable: verifiable
redaction of secrets, and a signed Evidence Bundle a reviewer can verify offline
with no NovaFabric runtime. This is the compliance-supporting surface of the
product — it produces evidence that *supports* compliance workflows; it does not
certify or guarantee compliance.

### nova redact

Re-scan a capsule and update its `redaction-proof.json`. Use this if you want
to re-run the scanner with a different strategy, or if you received a capsule
with potentially unscanned secrets.

**Re-scan with default strategies** (mask in-place):

```bash
nova redact ~/.novafabric/capsules/01HX.../
```

**Re-scan with strategy overrides.** Choose `mask` (default), `hash`
(SHA-256 first-8 hex chars), or `drop` (remove the field entirely):

```bash
nova redact <capsule> --strategy-override openai-api-key:hash
nova redact <capsule> --strategy-override pii-email:drop
```

**Mark a finding as an acknowledged false positive:**

```bash
nova redact <capsule> --mark-unsafe-skip <finding-id> --rationale "test fixture — not a real key"
```

False positives recorded in `unsafe_skips` are preserved across future
re-scans unless you clear them:

```bash
nova redact <capsule> --clear-unsafe-skips
```

Capsules with `unsafe_skips` entries block `nova export-evidence` unless you
pass `--allow-unsafe-skips` — so an acknowledged false positive is always a
deliberate, recorded decision, never a silent one.

---

### nova export-evidence

Build a signed **Evidence Bundle** ZIP from a capsule. The bundle is a
self-contained, verifiable archive: a copy of the capsule, its lineage edges, the JSON schemas, Ed25519-signed in-toto DSSE attestations, and a `manifest.json` with a SHA-256 for every file plus the verification recipe
([full list](https://novafabric.ai/docs/architecture/sealing-and-verification/#what-is-in-the-bundle)).
A reviewer can verify the bundle with nothing but `sha256sum` and an
ed25519 verifier — **no NovaFabric runtime required.** This is the primitive
that makes a capsule portable audit evidence you own.

**Generate a signing key** (one time): `nova init` writes an Ed25519 pair to
`$NOVAFABRIC_HOME/keys/signing_key.pem` (private, mode 600) and
`signing_key.pub.pem` (public). Re-running `nova init` keeps an existing pair.

```bash
nova init
```

**Build the bundle:**

```bash
nova export-evidence <capsule> \
  --key ~/.novafabric/keys/signing_key.pem \
  --output evidence.zip
```

**Options:**

- `--key PATH` — PEM-encoded ed25519 private key. Not defaulted: without it the
  command exits 1 and prints the path `nova init` would use.
- `--output PATH` / `-o` (required) — output ZIP path
- `--allow-unsafe-skips` — export even if `redaction-proof.json` contains acknowledged false positives
- `--timestamp` / `--timestamp-url URL` / `--timestamp-optional` — opt-in RFC 3161
  timestamp of the bundle (off by default; see `nova export-evidence --help`)
- `--sigstore` — publish the run attestation's DSSE envelope to a Rekor transparency
  log (see the note below)

**Bundle layout:**

```
evidence.zip
├── manifest.json                       # index + manifest_hash
├── run-capsule/                        # byte-identical capsule copy
├── lineage-subgraph/edges.jsonl        # lineage for this run
├── attestations/                       # in-toto Statement v1, DSSE-enveloped
│   ├── run.intoto.json
│   ├── redaction.intoto.json
│   ├── lineage.intoto.json
│   └── bundle.intoto.json
├── signatures/                         # raw signature + public key per envelope
│   ├── *.sig
│   └── *.cert
├── schemas/                            # all JSON schemas vendored
└── README.md                           # human-readable verification recipe
```

> **Note.** `--sigstore` is Rekor *publication*, not keyless signing: when
> `NOVA_REKOR_URL` is set it uploads the run attestation's DSSE envelope to that
> Rekor log and prints the entry id; when it is unset it prints a warning and the
> export still exits 0. The bundle is always signed by `--key`. Sigstore keyless
> signing is **planned**.

---

### Cryptographic sealing (experimental)

> **Status: experimental — shipped in v0.10+, interfaces may change before the
> v1.0 schema freeze.**
>
> The **NovaSeal** in-process signing core is implemented and tested: DSSE
> signing (ECDSA P-256), an **opt-in**
> [RFC 3161](https://www.rfc-editor.org/rfc/rfc3161) trusted timestamp (since v0.103,
> ADR-0292: with no `tsa_url` in `novaseal.yaml` no timestamp is requested and no network
> call is made — earlier releases silently defaulted to FreeTSA), and an
> append-only SQLite Merkle log, verified by `nova verify <capsule>` — which
> reports `signature_ok`, `timestamp_ok`, and `log_integrity_ok`. Verification is
> self-contained: new seals carry their Merkle inclusion proof, so an auditor needs only the
> capsule — no `novaseal.yaml` and no copy of your log; a check that cannot run prints
> `NOT CHECKED` / `NOT PRESENT` and does not fail the command. Sealing is
> opt-in via `$NOVAFABRIC_HOME/novaseal.yaml` (`~/.novafabric/novaseal.yaml` by default); maker-checker seal flows are
> `nova seal propose / approve / verify` (ADR-0059). See the
> [NovaSeal configuration guide](https://novafabric.ai/docs/novaseal-configuration/) and the
> [CLI reference](https://novafabric.ai/docs/cli-reference/trust/#nova-verify-capsule).

**Still planned** (ADR-0041 design intent, not shipped): the dedicated,
network-hardened NovaSeal signing *service*, qualified timestamps,
Sigstore-keyless signing as the default path, and WORM-backed retention at the
seal layer. For the most stable portable proof today, use the
[Evidence Bundle](#nova-export-evidence) above — ed25519-signed bundles with
in-toto DSSE attestations and verifiable redaction proofs, verifiable offline.

---

## Non-Python client capture

For agents that do not run in Python — Claude Code, Cursor, Continue.dev,
Node.js, Go, Rust — NovaFabric provides two transparent proxy commands that
capture LLM API calls without modifying the client. Both write into the same
capsule schemas as the in-process hooks.

### nova mcp-proxy (experimental)

A transparent proxy that sits between an MCP client and an upstream MCP server,
recording every `tools/call` request/response pair into a capsule.

Two transport modes are supported.

**Stdio mode (default):** wraps the upstream MCP server process.

```bash
NOVAFABRIC_CAPSULE_DIR=/path/to/.novafabric/runs/01HX...
nova mcp-proxy -- npx -y @modelcontextprotocol/server-filesystem /tmp
```

Or with an explicit flag:

```bash
nova mcp-proxy \
  --capsule-dir .novafabric/runs/01HX.../ \
  -- /usr/local/bin/my-mcp-server --flag
```

If `--capsule-dir` is omitted and `NOVAFABRIC_CAPSULE_DIR` is not set, a fresh
capsule directory is allocated automatically under `.novafabric/runs/`.

**Claude Desktop integration.** Replace the upstream server entry in
`claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "filesystem": {
      "command": "nova",
      "args": [
        "mcp-proxy",
        "--capsule-dir", "/Users/me/.novafabric/runs/01HX...",
        "--",
        "npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"
      ]
    }
  }
}
```

**HTTP mode (v0.6.3):** listens on a TCP port and forwards POST/SSE to an
upstream HTTP MCP server.

```bash
nova mcp-proxy \
  --listen 127.0.0.1:8765 \
  --upstream-url http://upstream-mcp.internal:9000/mcp
```

Stdio and HTTP modes are mutually exclusive per invocation. One proxy
invocation handles one upstream server.

Records use the same `tool-calls.jsonl` schema as the SDK hook, plus an
extension field `extensions["io.novafabric.capture_method"] = "proxy"`.

**Status: experimental.** The API may change.

---

### nova api-proxy (experimental)

A transparent HTTP proxy for non-Python LLM clients. The client points its
`*_BASE_URL` environment variable at the proxy; the proxy forwards to the
upstream LLM API and records every call into `model-calls.jsonl`.

```bash
# Terminal 1: start the proxy
nova api-proxy \
  --upstream-url https://api.openai.com \
  --listen 127.0.0.1:8765

# Terminal 2: point your LLM client at the proxy
export OPENAI_BASE_URL=http://127.0.0.1:8765
claude ...     # Claude Code
# or: cursor, continue, any Node/Go/Rust LLM client
```

For Anthropic:

```bash
nova api-proxy \
  --upstream-url https://api.anthropic.com \
  --listen 127.0.0.1:8765

export ANTHROPIC_BASE_URL=http://127.0.0.1:8765
```

**Options:**

| Flag | Default | Description |
|---|---|---|
| `--upstream-url` | (required) | The LLM API base URL to forward to |
| `--listen` | `127.0.0.1:8765` | Proxy listener address as `host:port` |
| `--capsule-dir` | auto-allocated | Where to write `model-calls.jsonl`; falls back to `NOVAFABRIC_CAPSULE_DIR` |
| `--upstream-timeout` | `60.0` | Timeout per upstream read, in seconds (httpx applies it to each connect/read, not to the whole call) |

**The proxy buffers every response.** It relays the upstream status, body and
headers (including `retry-after`, `x-should-retry`, `x-ratelimit-*` and
`request-id`, so client SDK retries see them) only after the whole upstream
response has arrived. A `stream: true` client therefore receives nothing until
the model finishes — time to first token equals total latency (**planned**:
relaying chunks as they arrive). For the record, an SSE stream is merged into
a synthesized non-streaming envelope.

The proxy is base-URL override only. No TLS MITM, no local CA.

**Status: experimental.** ADR-0026.

---

## Asset registry

The Asset Registry is NovaFabric's identity layer: a local SQLite database at
`~/.novafabric/registry.db` (override: `NOVAFABRIC_DB_PATH`) that tracks AI
assets — models, agents, prompts, tools, datasets, evaluation suites, and
deployment endpoints — with lifecycle statuses and promotion history. Each asset
is addressed as `name@version` and pinned to a git SHA at registration.

> **Promotion is governance metadata only.** Promoting an asset updates one DB
> record. It does **not** restart, deploy, or redeploy anything — it records a
> lifecycle decision, and (for agents) gates on a passing eval.

### nova register

Register an asset from a YAML spec file.

```bash
nova register my-model.yaml
nova register research-agent.yaml
```

**Model spec example:**

```yaml
novafabric_spec_version: "1"
asset_type: model
name: fraud-detection-model
version: 1.2.0
status: development
description: "XGBoost fraud detection model trained on transactions dataset"
spec:
  framework: xgboost
  artifact_path: s3://ml-artifacts/fraud-detection/v1.2.0
```

**Agent spec example:**

```yaml
novafabric_spec_version: "1"
asset_type: agent
name: support-triage-agent
version: v1.0.0
status: development
description: "LLM agent that triages incoming support tickets"
spec:
  model:
    provider: anthropic
    name: claude-haiku-4-5-20251001
    temperature: 0.1
  tools:
    - search_knowledge_base
    - create_ticket
  prompts:
    system: "You are a support triage assistant. Classify incoming tickets by priority."
  policies: []
  evals:
    - triage_accuracy_suite
```

Spec files are validated strictly: a key NovaFabric does not define — at the top level or
inside `spec:` — is a validation error that names the key, so a typo such as `suite_name:`
for `suite:` is caught instead of silently dropped. Put vendor or custom data under the
optional top-level `extensions:` mapping (reverse-DNS keys, kept verbatim), or in `tags:` /
`external_refs:` (string values). (Before this change, unknown keys were accepted and
discarded.)

```yaml
extensions:
  com.example.owner: fraud-team
  com.example.cost-centre: {id: 4711}
```

Supported asset types and their `spec` fields (`src/novafabric/spec/models.py`):

| `asset_type` | Required `spec` fields | Optional `spec` fields |
|---|---|---|
| `model` | `framework`, `artifact_path` | — |
| `agent` | `model` (`provider`, `name`), `tools`, `prompts`, `policies` (may be `[]`), `evals` (at least one) | `memory`, `model.temperature` |
| `prompt` | — (`spec:` itself is optional) | `template` |
| `tool` | — (`spec:` itself is optional) | `entrypoint` |
| `dataset` | — (`spec:` itself is optional) | `path` |
| `evaluation` | — (`spec:` itself is optional) | `suite` |
| `deployment` | — (`spec:` itself is optional) | `endpoint` |

Exits 0 on success, 1 on validation error or duplicate. Use `nova validate
<spec.yaml>` to validate without registering.

---

### nova suggest-register

Analyze captured Run Capsules and suggest assets to register. This inverts the
onboarding workflow: capture first, then let NovaFabric propose what to register
from observed evidence (models seen, tools called, agent command).

```bash
# Interactive: scan 10 most recent capsules, prompt per suggestion
nova suggest-register

# Write draft YAML files without registering
nova suggest-register --draft-only --output-dir ./drafts/

# Auto-register high-confidence suggestions (models + tools, skip agents)
nova suggest-register --auto --min-confidence 0.8 --skip-types agent

# Analyze a specific run by ID
nova suggest-register 01HXAY7M5JZ8R7K4P9DPBYK2WX
```

**Confidence levels:** models = 100% (every observed call), tools = 80% (min 2 calls),
agents = 70%. Post-capture, a one-line hint is printed when unregistered assets are
detected. Disable with `NOVAFABRIC_SUGGEST=0`.

---

### nova list

List registered assets. Filter by type or lifecycle status. Use `--stale` to surface
assets with no promotion, consumption, or eval activity in the last N days.

```bash
nova list
nova list --type agent
nova list --type model --status staging
nova list --status production

# Stale asset detection
nova list --stale                          # assets inactive > 30 days
nova list --stale --stale-days 60          # custom threshold
nova list --stale --status production      # stale production assets only
```

`--stale` output adds `last_activity_at` and `days_stale` columns. Activity includes
any promotion, capsule consumption (`assets.jsonl`), or eval run.

---

### nova inspect

Show full metadata for an asset. If version is omitted, shows the latest.

```bash
nova inspect fraud-detection-model@1.2.0
nova inspect support-triage-agent          # latest version
```

Output includes the full spec YAML, lifecycle status, git commit at
registration, and eval results for agent assets.

---

### nova promote

`nova promote` is a sub-group with four commands (`direct`, `keygen`, `propose`, `approve`). Valid lifecycle transitions:

```
development → staging → production → archived
```

Direct transitions (e.g. `development → production`) are permitted. Once
`archived`, no further promotion is possible.

#### nova promote direct

Single-actor promotion (original behaviour):

```bash
nova promote direct fraud-detection-model@1.2.0 --to staging
nova promote direct fraud-detection-model@1.2.0 --to archived
```

**Protected stages (ADR-0312).** `production` is protected. `nova promote direct --to
production` is refused unless an OPA policy explicitly allows the `promote_direct`
action, and the shipped default policy denies it. With no OPA installed there is no
explicit allow, so it is refused. `--force` does not bypass this. Use `propose` +
`approve` below, or see the CLI reference for loading an allow policy. The dashboard's
Promote button and the HTTP promote endpoints go through the same gate and answer 403.

**Eval gate.** Agent assets require a passing `nova eval` result before
promotion to `staging` or `production`. Use `--force` to override
(requires interactive confirmation; recorded as forced in the audit trail):

```bash
nova promote direct support-triage-agent@v1.0.0 --to staging --force
```

**SLSA provenance on promotion (experimental).** Add `--slsa-provenance` to emit a
DSSE-signed `slsa.dev/provenance/v1` attestation for the promotion, and
`--slsa-ml-profile` to emit the **SLSA-for-ML** profile instead — its `buildDefinition`
captures dataset versions/hashes and seeds, and its byproducts bind the promoted model to
the digest of its recorded eval results (omitted when none is recorded) (NF-057). Both verify with `nova verify-envelope` or stock
`cosign`:

```bash
nova promote direct my-model@1.0.0 --to staging \
    --slsa-provenance --slsa-ml-profile --slsa-out my-model.slsa.json
```

#### nova promote propose + nova promote approve

Maker-checker two-step flow for regulated deployments (ADR-0058, verified since
ADR-0312). Two different people, each with their own Ed25519 key, and each key bound
to that person by an operator-held **signer trust list**.

**One-time setup.**

```bash
# Each signer creates a key; this prints the trust-list entry for it.
nova promote keygen --identity alice
nova promote keygen --identity bob
```

An operator copies the printed entries into the trust list. It is read from
`NOVAFABRIC_PROMOTE_SIGNERS` if set, otherwise `$NOVAFABRIC_HOME/promote-signers.yaml`.
Each entry should keep only the roles that person holds:

```yaml
signers:
  - identity: alice
    public_key: <from keygen>
    roles: [proposer]
  - identity: bob
    public_key: <from keygen>
    roles: [approver]
```

**Each promotion.**

```bash
# Step 1 — maker proposes (signature verified before the proposal is stored)
nova promote propose fraud-detection-model@1.2.0 --to production --identity alice

# Step 2 — checker approves (both signatures verified, then the asset is promoted)
nova promote approve fraud-detection-model@1.2.0 --identity bob
```

What is enforced today:

- `--identity` is **required** on both commands. It must name a key that already exists.
  Neither command ever creates a key.
- Each signature is verified with the public key the trust list binds to the claimed
  identity. An unlisted key, a key bound to another name, a missing role, or a
  signature that does not verify is refused: exit `3` on the proposal side, `4` on the
  approval side.
- The approval is bound to one proposal: its id, target stage and the maker's signature.
  Editing the stored proposal makes approval fail.
- Separation of duties compares the bound identities and keys, so one person cannot
  approve their own proposal under a second name. Self-approval exits `6`.
- Proposals stored before ADR-0312 still list, as `unverified`. They cannot be approved.
  A new `propose` supersedes them.

**Every single-actor route into production is gated the same way.** `nova promote direct`,
`nova prompt promote`, the dashboard and `nova serve` promote endpoints, the `nova server`
route `PUT /v0/assets/{id}/promote` (403 `promote_direct_denied`), and a `nova rollback` to a
version that was never in production all need an explicit `promote_direct` policy allow;
`--force` does not bypass it. `PUT /v0/assets/{id}/promote` records the authenticated principal
(`server:<subject>`) as `promoted_by`, as registration does; a request-body `actor` is ignored.

**Limit.** The trust list is the trust anchor. Anyone who can edit it — or who can set
`NOVAFABRIC_PROMOTE_SIGNERS` / `NOVAFABRIC_HOME` for the process doing the approving — can bind
a second key to a second name. Keep it owned by an operator and read-only to signers, and do
not let signers choose the environment of the process that verifies them (ADR-0312 Limits).

---

### nova rollback

Roll back an asset to its most recent previous production version in one command.
Atomically archives the current production version and restores the previous one.
Designed for incident response — no manual registry queries needed.

```bash
nova rollback my-agent --actor on-call-eng
nova rollback my-agent --to v1.8.0 --actor on-call-eng   # explicit target
```

Steps performed in a single DB transaction:
1. Find the current `production` version (error if none).
2. Find the most recent prior `production` version (auto, or use `--to`).
3. Archive the current production version.
4. Promote the prior version back to `production`.
5. Write an audit log entry with a `rollback_reason` and a `gate` field.

"Prior production version" comes from the registry's append-only status history. A version
that was in production before is restored at once, even if it is archived — that is what
incident response needs. A `--to` target that was **never** in production is refused unless
the `promote_direct` policy action explicitly allows it (ADR-0312), the same gate as
`nova promote direct`; otherwise `promote --to staging` followed by a rollback would be a
single-person path into production. `--actor` is recorded in the audit trail.

---

### nova capture — asset status gate

Before spawning the subprocess, `nova capture` can verify that a named asset is in
the required lifecycle status. Nothing is written to disk if the check fails.

```bash
# Hard block — exits non-zero if my-agent@v1 is not in staging or production
nova capture --asset my-agent@v1 --require-asset-status staging,production -- python agent.py

# Soft warn — logs a warning but does not block
nova capture --asset my-agent@v1 --warn-if-asset-status development -- python agent.py

# Block if the asset is not registered at all (default: warn only)
nova capture --asset my-agent@v1 --require-asset-status production --require-registered -- python agent.py
```

This is an opt-in gate. Without `--asset`, capture behaviour is unchanged.

---

### nova eval

Run evaluation suites declared in an agent's spec and store the results in
the registry. Suites are resolved via the `novafabric.evals` Python entry-point
group.

```bash
nova eval agent support-triage-agent@v1.0.0
```

After a passing eval, `nova promote direct <name@version> --to staging` will succeed without
`--force`. `production` is a protected stage: reach it with `nova promote propose` +
`nova promote approve`, unless a policy explicitly allows `promote_direct` (ADR-0312).

The bundled **standard eval suites** (GAIA, SWE-bench, AgentBench, MMLU, Smoke)
run in OCI-pinned containers, and promotion can be Rego-gated to block on a
regression against a recorded baseline.

**Guard against benchmark contamination (experimental, NF-028).** Contamination
silently inflates scores, so NovaFabric records the dataset + split content hashes an
eval ran against. Check a capsule against a configurable registry of known-bad hashes:

```bash
nova eval contamination-check ./my-capsule --registry known-bad.json --json
```

It reports a status per dataset (`current` / `superseded` / `contaminated` / `unknown`)
and **exits `4`** when any dataset is contaminated or superseded, so CI can gate on it.
It fails closed: a facet it cannot read or validate is reported as `invalid`, with a
reason, and also exits `4`.
Detection only — no remediation. See the [feature tour](https://novafabric.ai/docs/tutorials/feature-tour/#17-prove-supply-chain-provenance--eval-integrity)
for the dataset provenance and SLSA-for-ML surfaces that pair with it.

---

### nova report

Generate an asset inventory report. Defaults to Markdown on stdout.

```bash
nova report                        # Markdown to stdout
nova report --format json          # JSON to stdout
nova report --output report.md     # write to file
nova report --format html --output inventory.html   # self-contained HTML + by-type chart (ADR-0201)
nova report --format pdf --output inventory.pdf      # requires the optional WeasyPrint extra
```

`--format pdf` requires `--output` and the optional `pip install 'novafabric[compliance]'`
extra (WeasyPrint); without it the command exits 1 with an install hint.

Sample Markdown output:

```markdown
# NovaFabric Asset Report

## Summary
| Type | Count |
|---|---|
| model | 3 |
| agent | 2 |
...
```

---

## Local dashboard

### nova serve (experimental)

An opt-in local HTTP dashboard for browsing capsules, registry assets, and the
lineage graph. **The CLI remains the canonical interface**; the dashboard is a
read-oriented satellite. Every dashboard mutation displays the equivalent `nova`
command and is logged to `~/.novafabric/dashboard-audit.jsonl`, so nothing the
dashboard does is invisible to the CLI-first audit trail.

**Install the optional extra** (one time):

```bash
pip install 'novafabric[serve]'
```

This adds FastAPI and uvicorn (both Apache-2.0 / MIT licensed).

**Start the server:**

```bash
nova serve --experimental
```

The `--experimental` flag is mandatory and acknowledges that the dashboard
API may change.

The terminal prints a URL with an embedded session token:

```
Dashboard: http://127.0.0.1:4321/?token=<token>
API docs:  http://127.0.0.1:4321/api/docs?token=<token>
```

Click the URL to open the dashboard in your browser. The token is also
written to `$NOVAFABRIC_HOME/.serve-token` (mode 0600) — the start-up panel
prints the resolved path, which is `~/.novafabric/.serve-token` only when
`NOVAFABRIC_HOME` is unset.

The token is **not** rotated on every restart. `nova serve` reuses an existing
`.serve-token` file so that a restart does not break open browser sessions, so
a URL you were given earlier keeps working after a restart — and the token
stays readable on disk (mode 0600) after the server exits. Delete the file if
you want the next start to mint a fresh token.

**Flags:**

| Flag | Default | Description |
|---|---|---|
| `--experimental` | (required) | Acknowledges the experimental gate |
| `--port` | `4321` | TCP port |
| `--host` | `127.0.0.1` | Bind address; localhost only without `--insecure` |
| `--capsule-dir` | `$NOVAFABRIC_CAPSULE_DIR`, else `$NOVAFABRIC_HOME/capsules` | Where to look for capsules (where `nova capture` writes) |
| `--db-path` | `$NOVAFABRIC_DB_PATH`, else `$NOVAFABRIC_HOME/registry.db` | Registry/lineage SQLite path |
| `--no-browser` | off | Do not auto-open a browser tab |

**Capsule index.** `nova serve` keeps a `runs_cache` SQLite index so the Runs
tab stays fast regardless of how many capsule directories are on disk. The index
is built on startup and refreshed every 2 s by `CapsuleWatcher`. If capsules
were added while the server was stopped, force a full re-index:

```bash
nova ingest-capsule --all                       # re-index everything
nova ingest-capsule <run_id>                    # index a single capsule
nova ingest-capsule --watch                     # foreground watcher loop
```

Two watcher backends: `WatchdogBackend` (`pip install novafabric[watch]`; uses
inotify/FSEvents) and `PollingBackend` (zero extra deps). The default, `auto`, uses
watchdog when it is installed and falls back to polling otherwise. Force one with
`NOVA_WATCHER_BACKEND=polling|watchdog` and set the poll period with
`NOVA_WATCHER_INTERVAL=<seconds>` (default 2); an unknown value is refused at start-up.

**Navigating.** The 30 tabs are grouped in the sidebar under seven headings:
Overview · Runs & Debug · Govern & Promote · Provenance & Trust · Compliance ·
Platform · Reports & Export. Since v0.98.3 the groups **start collapsed**, each
showing its tab count — click a heading to expand it. The group holding the
tab you are on is always expanded, so your current location is never hidden,
and whatever you expand or collapse is remembered in that browser. Every tab is
deep-linkable as `?tab=<id>`, and the
Compliance tab is itself a hub whose five panel groups (Frameworks · Audits ·
Privacy · Exports · Assurance) are deep-linkable as `?sub=<group>`. Navigation
shortcuts are mnemonic two-key sequences — press `g` then the tab's letter
(`g h` Home, `g r` Runs, `g c` Compliance); press `?` for the full map, and
`⌘K` / `Ctrl-K` for the command palette. (v0.97.0 replaced the earlier
positional `1`–`9` keys with these sequences; tab ids and `?tab=` links are
unchanged.)

**What the dashboard covers:**

- **Runs tab** — list, search, and filter capsules; status filter pill-bar; hover copy-run-ID; inspect file tree; validate schema; view secret scan results; replay (forensic / dry-run); redact; export evidence. Multi-select up to 5 for N-run diff.
- **Registry tab** — list and inspect registered assets; register; run evals; promote (`direct` sub-command); bulk-promote checkbox; compare two spec versions (diff table); eval trend sparkline.
- **Evidence tab** — list signed bundles; in-browser ed25519 verify; full server-side cryptographic verify of the bundle.
- **Holds tab** — place and release legal holds on registries; view all active holds with duration and reason; sidebar count badge.
- **Lineage tab** — interactive DAG rendered with React Flow; provenance, blast-radius, and replay-chain highlight modes; ancestry breadcrumb; click or double-click a node to select it.
- **Diff tab** — structural diff; 2-run or N-run (up to 5) comparison; stacked collapsible cards in N-run mode; URL-persistent `?run_ids=a,b,c` for sharing.
- **Audit tab** — unified mutation audit log (every dashboard write action with equivalent `nova` command); action-type filter.
- **Policy tab** — interactive OPA/Rego policy tester; ALLOW/DENY badge; explain toggle for full OPA trace output. Checking the packaged policies needs a `read` token; submitting your own Rego needs `admin`, and `opa` always runs sandboxed (no server environment, no `opa.runtime`/`http.send`/`net.lookup_ip_addr`).
- **Commands tab** — live command builders across journey tracks with copy buttons.
- **Home tab** — staleness indicator (amber border on resume cards > 24 h).
- **Capture tab** — recent capsules panel with "Open folder" links for local paths.
- **Runs filter bar and saved views** *(experimental)* — `status:error -model:gpt-4o` filters are
  `nova query --where` predicates, with a node / root / tree scope; "save current as" writes a
  `nova view` file that `nova view run` can re-run. Honest aggregates say "no value" and
  refuse rather than show `$0.00` for an unpriced model. See
  [dashboard.md](https://novafabric.ai/docs/dashboard/#filter-bar-experimental--adr-0232-d1d3-adr-0233).
- **Evidence cart** *(experimental, `admin` scope)* — collect runs, then export them as one signed
  Evidence Bundle. See [dashboard.md](https://novafabric.ai/docs/dashboard/#evidence-cart-experimental--adr-0239).
- **Dashboards tab** *(experimental, `g 4`)* — render, validate and save ADR-0235 widget and
  dashboard JSON files (the same files `nova dashboard` manages). See
  [dashboard.md](https://novafabric.ai/docs/dashboard/#dashboards-view-experimental--adr-0235--adr-0236).

> The dashboard also surfaces a component-status view covering the
> cluster-scale subsystems (NovaSeal, collector, object store, metadata DB,
> parent/child capsules). These shipped in v0.10+ as **experimental /
> prototype** tiers — implemented and tested, but not validated at target
> scale. Check each component's maturity label in [ROADMAP.md](https://github.com/MSKazemi/novafabric/blob/main/ROADMAP.md)
> rather than treating presence in the status view as production readiness.

**What requires the CLI.** Some operations are intentionally CLI-only:
`nova report`, lineage time-travel, OpenLineage emission, `nova mcp-proxy`,
`nova api-proxy`, cluster runners, mocked replay, semantic/exact replay,
`nova promote propose/approve` (maker-checker). See
[docs/dashboard.md](https://novafabric.ai/docs/dashboard/) for the full capability matrix.

**Security model:**
- Localhost only by default (binds `127.0.0.1`)
- Session token required on every `/api/*` request — either as
  `?token=<token>` or as an `Authorization: Bearer <token>` header (when the
  header is present it is authoritative)
- DNS-rebinding defence (`Host` header validated)
- CORS restricted to `localhost` and `127.0.0.1` origins
- Every write action is confirm-gated and audit-logged

Stop the server with `Ctrl+C`. The session token is invalidated on stop.

**Status: experimental.** The dashboard API (endpoint shapes, field names)
may change between minor versions.

---

## Environment variables

| Variable | Default | Purpose |
|---|---|---|
| `NOVAFABRIC_HOME` | `~/.novafabric` | Root directory for all internal NovaFabric data. Set this once to redirect all files (registry.db, .serve-token, dashboard audit log) to a shared location. |
| `NOVAFABRIC_DB_PATH` | `$NOVAFABRIC_HOME/registry.db` | SQLite path for asset registry and lineage graph. Overrides `NOVAFABRIC_HOME` for this path only. |
| `NOVAFABRIC_METADATA_DB_PATH` | `$NOVAFABRIC_HOME/metadata.db` | SQLite path for the MetadataStore (runtime and `nova db upgrade`). `NOVAFABRIC_DB_PATH` does not select it. |
| `NOVAFABRIC_URL_REGISTRY` | (bundled) | Override path for the wire-level URL classification registry |
| `NOVAFABRIC_CAPSULE_DIR` | — | Capsule storage directory. Set by `nova capture` in the subprocess; used by hook loader and proxy commands. Set explicitly to redirect captures to a shared path (e.g. a shared NFS mount). |
| `NOVAFABRIC_SPAN_ID` | — | Root OTel span id injected into the subprocess by the orchestrator |
| `NOVAFABRIC_SLURM_SHARED_DIR` | — | Shared filesystem path for SLURM live tests (ops / CI use) |
| `NOVAFABRIC_SUGGEST` | — | Set to `0` to disable the post-capture `suggest-register` hint |
| `OPENLINEAGE_URL` | — | HTTP endpoint for automatic OpenLineage emission at capture time |
| `OPENLINEAGE_FILE` | — | File path for OpenLineage emission (fallback if `OPENLINEAGE_URL` not set) |
| `NOVAFABRIC_DASHBOARD_AUDIT_FILE` | `$NOVAFABRIC_HOME/dashboard-audit.jsonl` | Audit log destination for dashboard mutations. Overrides `NOVAFABRIC_HOME` for this path only. |
| `NOVA_WATCHER_BACKEND` | `auto` | Capsule-index watcher backend (`auto`, `polling` or `watchdog`); an unknown value is refused. `auto` uses watchdog when installed, else polling. `nova ingest-capsule --watch --backend` overrides it; `nova serve` has no such flag. |
| `NOVA_WATCHER_INTERVAL` | `2.0` | Poll interval in seconds for the capsule-index watcher; an explicit `--interval` flag wins. |
| `NOVA_CONTENT_INDEX` | on | Set to `off` to disable content indexing at ingest (experimental, ADR-0204) |
| `NOVA_CONTENT_INDEX_MAX_DOC_KB` | `64` | Per-doc text cap for the content index (operator tuning) |
| `NOVA_CONTENT_INDEX_MAX_DOCS` | `10000` | Per-capsule doc cap for the content index (operator tuning) |

---

## Cost analytics (experimental)

Beyond the offline `nova cost estimate` and the ClickHouse-backed `nova cost
report` (both covered in the [CLI reference](https://novafabric.ai/docs/cli-reference/evidence-fabric/#nova-cost-report)),
four additional `nova cost` subcommands report **descriptive** cost evidence over
records you already hold. Each is **experimental**, read-only, and never a verdict —
none applies a threshold, quota, or pass/fail; whether a figure is acceptable is
the operator's call. Each also takes `--json`.

- **`nova cost attribute <runs.json>`** (ADR-0146, NF-148) — splits recorded spend
  into productive vs wasted outcomes with a per-status breakdown, from a
  `{runs: [{run_id, status, cost}], productive_statuses?}` document.
- **`nova cost fairness <totals.json>`** (ADR-0146, NF-150) — per-agent fairness
  ledger (each agent's share, Gini coefficient, max/mean ratio) over per-dimension
  resource totals (`cost` / `energy` / `calls`) in a `{totals: {dimension: {agent:
  total}}}` document.
- **`nova cost usage-breakdown <manifest.json>`** (ADR-0132) — token usage-type
  composition of a capsule (each usage type's share, cached-read ratio, reasoning /
  multimodal flags). Composition only — no cost, no verdict; honours "absent !=
  zero".
- **`nova cost rollup <delegation.json> <capsule|attribution.json>`** (ADR-0146,
  NF-142) — rolls the per-agent `cost_attribution` facet (NF-141) **up** the
  acted-as delegation chain (ADR-0106): each hop's own cost and subtree cost, plus a
  conservation block (root subtree vs run total, exact `Decimal` equality) that
  names unchained and unattributed cost. Cycles and broken chains are reported as
  findings (`basis: partial`), never a crash. Report-only — nothing is written to
  the capsule, and grant signatures are not re-verified.

See the [CLI reference](https://novafabric.ai/docs/cli-reference/evidence-fabric/#nova-cost-attribute-experimental-adr-0146)
for flags, input shapes, and exit codes.

---

## What shipped experimental in v0.59

v0.59.0 added a large cohort of **experimental** command groups — first slices of
the Langfuse-parity ADRs (0112–0141) plus interop and forensics surfaces. All are
additive, off unless you opt in, and their interfaces may change. This guide does
not duplicate them; each is fully documented in the
[CLI reference](https://novafabric.ai/docs/cli-reference/), and the grouped overview lives in the
[v0.59.0 release notes](https://github.com/MSKazemi/novafabric/blob/main/docs/releases/v0.59.0.md):

| Area | Commands / surfaces (all experimental) |
|---|---|
| Prompt lifecycle | `nova prompt register/get/list/history/diff/compose/tree`, `nova label` (deployment labels + protected maker-checker moves) |
| Evaluation & annotation | `nova eval score config`, `nova annotate`, `nova score submit` (+ `novafabric.scores.submit`), `nova comment`, `nova experiment run/compare` |
| Capture completeness | `nova session new/add/list/reindex/show/replay/export/verify-bundle/import`, `nova graph agent`, `nova capture --capture-media` + `nova media list`, `--environment`, variant attribution (`--experiment`/`--variant`), observation log levels, `nova validate --schemas` |
| Offline analytics | `nova query`, `nova view`, `nova trend`, per-usage-type token accounting, `nova pricing` + `nova cost estimate` |
| Governance | `nova retention plan/apply/status/explain`, PII masking plugins (`--masker`), the budget promotion gate (Rego), `nova events` webhooks, SCIM 2.0 provisioning, partial SAML SSO (metadata + assertion-validation policy; live assertion consumption shipped opt-in in v0.73.0 and is off by default) |
| Portability & interop | `nova export --html`, `nova export-blob` + manifest `nova verify`, OTLP GenAI-span ingest (`POST /api/otlp/v1/traces`), `nova eval import-inspect/export-inspect`, `nova diagnose --intervene`, `nova pii status` |

---

## What shipped in v0.75–v0.94

A second large cohort landed after v0.59, mostly ADR-0101 (attribution/root-cause)
and a set of standalone trust primitives. All are **experimental**; some are
CLI-exposed today, others are **Python-API only** — no `nova` subcommand wraps
them yet, so build against the module, not a shipped command:

| Capability | Status | Where |
|---|---|---|
| Counterfactual root-cause search (ADR-0101 NF-018/020) | **CLI**, experimental | `nova diagnose <run-id> --search-root-cause [--max-interventions N]` — sweeps ranked causal-root candidates with bounded intervention replays until one confirms |
| Causal-graph back-trace (ADR-0101 NF-019/022) | Python API only | `diagnose.causal_root_candidates()` — used internally by `--search-root-cause`; no direct CLI verb |
| Span-level claim-grounding audit (ADR-0101 NF-021) | Python API only | `diagnose.audit_claims()` — flags a model-span claim `ungrounded` when no tool-span evidence precedes it; not wired into `nova diagnose` output yet |
| NATS lineage consumer (cap-006, ADR-0061/0066/0219) | **CLI**, experimental | [`nova lineage consume`](#nova-lineage-consume-experimental) |
| EU AI Act Art. 12 record-keeping export | **CLI**, experimental | `nova euaiact export` / `nova euaiact status` |
| Compliance cohort (ISO 42001/42005, Art.72 PMM, GPAI Art.53, NIST GenAI/CSA) | **CLI**, experimental | `nova export-compliance {iso42001,pmm,gpai53,genai-profile}` |
| Portable agent-passport projection (ADR-0149) | **CLI**, experimental | `nova passport issue` / `nova passport verify` |
| SAML SSO (ADR-0138) | **CLI**, experimental, partial | `nova server saml-metadata` emits SP metadata only; assertion-consumption is opt-in server config, not a CLI verb |
| x509 certificate-pinned signing identity (ADR-0055) | Python API only | `trust/novaseal/x509_identity.py` |
| x509 CA-bundle signer chain validation (ADR-0055 trust step 2) | **CLI**, experimental | `nova verify --ca-bundle <pem>` (or `ca_bundle:` in `novaseal.yaml`); library: `validate_certificate_chain()` in `trust/novaseal/x509_identity.py` |
| Crypto-agility hybrid-signature envelope (ADR-0072) | Python API only | `trust/novaseal/hybrid_signature.py` |
| `did:key` + Verifiable Credentials (ADR-0075) | Python API only | `trust/did.py` |
| "Acted-as" delegation chains (ADR-0106) | Python API only | `trust/delegation.py` |
| Transparency-log witness cosigning (ADR-0097) | Python API only | `trust/novaseal/witness.py` |
| Jurisdiction sovereignty site-seals (ADR-0077) | Python API only | `compliance/sovereignty.py` |

The Python-API-only rows above have no `nova` command and no dashboard panel —
they are real, tested modules (see the corresponding ADR for design intent),
but there is no shipped end-user workflow around them yet. Do not follow a
tutorial that shows a `nova` command for them; none exists. See
[Python API](https://novafabric.ai/docs/python-api/) and [`docs/developer-guide.md`](https://novafabric.ai/docs/developer-guide/)
for the module-level detail.

---

## Summary and next steps

You now have the full shipped `nova` command surface, organized around the five
primitives:

| Primitive | Commands |
|---|---|
| **Run Capsule** | `nova capture`, `@agent` decorator, runners, `nova validate`, `nova scan-secrets` |
| **Replay** | `nova replay` (forensic / mocked / semantic / exact; intervention experimental), `nova diff` |
| **Lineage** | `nova lineage provenance / blast-radius / replay-chain / time-travel / import / emit-openlineage` |
| **Evidence Bundle** | `nova redact`, `nova export-evidence` |
| **Asset Registry** | `nova register`, `nova suggest-register`, `nova list`, `nova inspect`, `nova promote`, `nova rollback`, `nova eval`, `nova report` |

Two journeys branch from the Run Capsule: **Capture → Replay → Diff** (developer:
what happened, what changed?) and **Capture → Seal → Verify → Audit** (trust: can I
preserve and verify it later?). Sealing is opt-in and `experimental` — see
[Cryptographic sealing](#cryptographic-sealing-experimental). Everything above
runs locally today, offline, with no accounts and no telemetry.

**A good next move, depending on your goal:**

- **Wire a CI regression gate** — capture a baseline, replay in `mocked` mode
  after a change, and run `nova diff --assert-no-regressions`. Add
  `nova scan-secrets --fail-on high` as a second gate.
- **Produce audit evidence** — `nova redact` then `nova export-evidence`, and
  hand the ZIP to a reviewer who verifies it with only `sha256sum` and an
  ed25519 verifier.
- **Understand impact before a change** — run `nova lineage blast-radius` on the
  asset you are about to update.

**Where to go from here:**

- [Getting Started](https://novafabric.ai/docs/getting-started/) — narrative walkthrough from install to first capsule
- [Concepts](https://novafabric.ai/docs/concepts/) — capsule structure, replay modes, lineage edge types
- [Local Dashboard](https://novafabric.ai/docs/dashboard/) — full capability matrix vs CLI, security model
- [Python API](https://novafabric.ai/docs/python-api/) — programmatic usage
- [Architecture](https://github.com/MSKazemi/novafabric/blob/main/docs/architecture.md) — how the subsystems fit together
- [Writing a hook plugin](https://novafabric.ai/docs/integrations/writing-a-hook-plugin/) — extend capture to new transports
- [Tutorials](https://novafabric.ai/docs/tutorials/) — getting started, why NovaFabric, capture internals, multi-agent, cluster scale, Langfuse comparison
- [ROADMAP.md](https://github.com/MSKazemi/novafabric/blob/main/ROADMAP.md) — what is planned, including the NovaSeal seal layer and cluster-scale tiers
