Python API Reference
NovaFabric is a CLI-first toolkit, but every capability the nova command
exposes is also reachable as a typed Python API. This reference documents the
public symbols for programmatic capture, replay, diff, lineage, registry,
spec validation, reporting, and signing — so you can embed NovaFabric in a
test harness, a CI script, an internal tool, or a notebook without shelling out.
What you will learn
- How to capture a run in-process with the
@agentdecorator, or capture an arbitrary command withCaptureOrchestrator. - How to replay a capsule in any of the five honest modes (
forensic,mocked,semantic,exact, and the experimentalintervention) and read back a structuredReplayResult. - How to structurally diff two capsules and serialise the report for a CI gate.
- How to build and query the lineage graph (
provenance,blast_radius,replay_chain). - How to register, list, fetch, and promote assets through the six-state lifecycle.
- How to validate asset specs and generate registry reports.
- How to sign, timestamp, and verify capsules with the NovaSeal signing core.
Everything below maps one-to-one onto a nova subcommand; where a CLI
equivalent exists it is noted so you can cross-check behaviour with
nova <command> --help.
Stability. All public symbols listed here are importable today and covered by tests. Most v0.x surfaces carry experimental maturity: they work, but interfaces may change before the v1.0 schema freeze. The Run Capsule and Evidence Bundle on-disk formats are not yet frozen — expect additive, backward-compatible schema changes until v1.0.
Table of contents
| Section | Import root | CLI equivalent |
|---|---|---|
| SDK decorator | novafabric.sdk.agent |
— (in-process) |
| Capture | novafabric.capture.orchestrator |
nova capture |
| Extended event recording | novafabric.capture.record |
— (in-process) |
| Replay | novafabric.replay |
nova replay |
| Diff | novafabric.diff |
nova diff |
| Lineage | novafabric.lineage |
nova lineage |
| Asset Registry | novafabric.registry.service |
nova register / nova list / nova promote |
| Spec validation | novafabric.spec |
nova validate |
| Report generation | novafabric.report.generator |
nova report |
| NovaSeal signing | novafabric.trust.novaseal |
nova seal / nova verify |
| Score submission | novafabric.scores |
nova score submit |
| REST client | novafabric.client |
— (server mode) |
| Utility | novafabric.capture |
— |
SDK decorator
from novafabric.sdk.agent import agent@agent(name, version, capsule_dir=None, deployment_environment=None, variant=None, session_id=None, session_sequence=None)
Wraps an agent function with capture hooks. LLM calls made inside the
function are recorded into a capsule. This is the in-process alternative to
nova capture — use it when you cannot (or do not want to) wrap the whole
process, e.g. a single function inside a larger long-lived service.
Parameters
| Parameter | Type | Description |
|---|---|---|
name |
str |
Asset name (matches a registered asset, if any) |
version |
str |
Asset version (semver) |
capsule_dir |
Path | str | None |
Capsule store, like nova capture --output-dir: each call writes its own capsule to <capsule_dir>/<run_id>/. If None, OTel spans only — no capsule is written. |
deployment_environment |
str | None |
Experimental (ADR-0126). Delivery-lifecycle tag for the run (e.g. production, staging), recorded verbatim as the additive optional deployment_environment manifest field with environment_source: sdk-arg. The nova capture --environment flag and the NOVAFABRIC_ENVIRONMENT env var take precedence. |
variant |
Mapping[str, Any] | None |
Experimental (ADR-0116, record-only). Which A/B experiment/variant an external allocator had active: a mapping with experiment_id, variant_id, assignment_source (plus optional variant_label, assigned_at, extensions), copied verbatim into the capsule's optional variant block. NovaFabric never assigns variants; an incomplete explicit block raises before the workload runs. NOVAFABRIC_VARIANT* env vars take precedence. |
session_id |
str | None |
Experimental (ADR-0122, record-only). ULID of the session this run is one turn of (create with nova session new); recorded as an additive optional back-reference — the session.json manifest stays authoritative. NOVAFABRIC_SESSION_ID takes precedence. |
session_sequence |
int | None |
Experimental (ADR-0122). The run's turn number within the session; requires session_id. NOVAFABRIC_SESSION_SEQUENCE takes precedence. |
All four tagging parameters are optional and additive: leave them unset and the capsule is byte-identical to previous releases. Invalid explicit values raise before the wrapped function runs; ambient env-var values that are invalid warn and are ignored rather than blocking the workload.
Returns — the decorated function's return value unchanged.
Example
from novafabric.sdk.agent import agent
import openai
client = openai.OpenAI()
@agent(name="summariser", version="0.2.0", capsule_dir="capsules/")
def summarise(text: str) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"Summarise: {text}"}],
)
return response.choices[0].message.content
result = summarise("Long document text here...")Every call to summarise() writes a new capsule under capsules/<run_id>/ (a
fresh ULID per call, logged as sdk_capsule_started). Releases up to v0.106.0
wrote every call into capsule_dir itself, so a second call overwrote
capsule.yaml while the JSONL streams appended: one capsule, several runs.
Behaviour
- Installs OpenAI + Anthropic hooks before calling the function.
- Removes hooks after the function returns (including on exception).
- On exception: writes a capsule with
status: failureand re-raises.sys.exit(n)recordsexit_code: n(sys.exit()/sys.exit(0)is a success, a message is1) and Ctrl-C recordsexit_code: 130; both are re-raised untouched. async defagents are supported: the decorated function stays a coroutine function, and the capture stays open until the coroutine finishes, so model and tool calls after anawaitare recorded. Releases up to v0.107.0 finalised the capsule before an async agent ran and recordedsuccess, 0 model calls.- Fail-open: capture never replaces your result or exception. If the capsule
store is unwritable, the call runs uncaptured (logged as
sdk_capsule_not_started). If a teardown step fails (for example, ENOSPC writingenv.lock), the capsule is still scanned and written but is not sealed, andmetadata.finalization_errornames the failed step, e.g.capture incomplete: env.lock: OSError. Invalid explicit tagging arguments still raise before the function runs. - Thread-safe: each call writes to its own capsule directory.
The capsule this produces has the same shape as one produced by
nova capture— you cannova validate,nova diff, andnova replay --mode forensic|semanticit the same way. It recordscapture_mode: sdk-decoratorand an@agent:<name>@<version>label instead of a command, sonova replay --mode mockedrefuses it (CapsuleNotReplayable): there is no command to re-run.
Capture
from novafabric.capture.orchestrator import CaptureOrchestrator, CaptureResultCaptureOrchestrator is the programmatic equivalent of nova capture <cmd>.
It captures a subprocess as a Run Capsule with no changes to the target
program — hooks are injected via sitecustomize.py over PYTHONPATH. A
capsule is written on both success and failure (a failed run sets
status: failure and records an error block).
CaptureOrchestrator(base_dir=None, runner=None, ...)
| Parameter | Type | Default |
|---|---|---|
base_dir |
Path | None |
./.novafabric/runs/ (relative to the working directory) |
runner |
RunnerSpec | None |
None — run locally; a RunnerSpec dispatches to docker/kubernetes/slurm/lsf/pbs like nova capture --runner |
pii_gate |
Any | None |
None |
legal_hold |
bool |
False — only acts with a pii_gate: the gate scans the manifest and, when it redacts anything, also writes the redacted copy to <base_dir>/../legal_hold/<run_id>/; a scan error exits 3. With a gate but no legal_hold, the scan is skipped. There is no nova capture flag for it |
mark_provenance |
bool |
False |
fast_emit |
bool |
False |
emit_spool |
bool |
False |
spool_dir |
Path | None |
None |
masking_pipeline |
Any | None |
None |
capture_media |
bool |
False |
Only base_dir and runner are covered by examples here; the other keyword
arguments mirror nova capture options — see nova capture --help and the
constructor's docstring.
CaptureOrchestrator.run(command) → CaptureResult
Captures a command execution as a capsule.
| Parameter | Type | Description |
|---|---|---|
command |
list[str] |
Command + arguments |
from pathlib import Path
from novafabric.capture.orchestrator import CaptureOrchestrator
result = CaptureOrchestrator(base_dir=Path(".novafabric/runs")).run(
["python", "agent.py", "--dataset", "data.csv"]
)
print(result.run_id) # ULID string
print(result.capsule_dir) # Path to capsule directory
print(result.exit_code) # intThe examples on this page pass capsules written this way, so their paths start with
.novafabric/runs/. That is the SDK default, not the CLI's: nova capture writes to
the capsule store, ~/.novafabric/capsules/<run_id>/ by default
(novafabric._paths.default_capsule_dir(), which honours NOVAFABRIC_CAPSULE_DIR
and NOVAFABRIC_HOME). For a capsule from nova capture, pass that directory instead.
CaptureResult
@dataclass
class CaptureResult:
run_id: str
capsule_dir: Path
exit_code: int
runner_status: str | None = None # e.g. "failed_setup": the workload never started
runner_error: str | None = None # the runner's own reason, when it gave oneexit_code alone cannot tell the workload ran and failed from the workload
never started (a missing command and a program that exits 127 both give 127);
only runner_status == "failed_setup" identifies the latter. None means the
runner did not say, not "completed".
Extended event recording (experimental)
from novafabric.capture import recordStatus: experimental (ADR-0209 P1; the API surface may change until it
has survived one release unchanged). The stable public façade for emitting
the extended capture events of ADR-0082 — guardrails, evaluators, state
transitions, memory operations, rerankers, vector retrievals, file events —
into whatever run is currently being captured (any capture mode: nova capture, adapters, @agent, in-process hooks).
Contract:
- Outside a capture run every call is a silent no-op returning
None— you can instrument library code unconditionally and it stays inert until a capture is active.record.active()tells you which case you are in. - Inside a run, each call appends one event to the run's dedicated JSONL
stream (fail-open, thread-safe;
run_id/capsule_idare injected by the run). All of these streams are covered by the capsule secret scanner, so free-text payloads are redacted at finalize like everything else. - The façade passes your payload fields through at every capture level (you
own your data). NovaFabric's own default-path wirings only attach payloads
(state dicts, document text) at
forensic/air_gappedcapture level.
record.active() -> bool
record.file_event(operation, path, *, size_bytes=None, success=True,
error=None, agent_id=None)
record.state_transition(step_index, state_digest_before, state_digest_after, *,
agent_id=None, state_before=None, state_after=None)
record.memory_operation(operation, memory_key, *, relevance_score=None,
freshness_seconds=None, agent_id=None, value=None,
origin_run_id=None, origin_memory_key=None,
origin_timestamp_utc=None)
record.guardrail(guardrail_name, outcome, *, category=None, score=None,
agent_id=None, details=None)
record.evaluator(evaluator_name, *, score=None, label=None, passed=None,
dataset_id=None, agent_id=None, rationale=None)
record.reranker(reranker_model, *, input_count=None, output_count=None,
documents=None, agent_id=None)
record.vector_retrieval(vector_store, *, phase="completed", operation="query",
collection=None, top_k=None, returned_count=None,
duration_ms=None, error=None, documents=None,
agent_id=None)network_event and human_approval are deliberately not exposed: the
wire-level hooks and nova seal propose own those streams, and a second
producer would invite double-recording.
record.wrap_retriever(fn, *, vector_store, collection=None, operation="query")
Wraps any sync callable retriever; emits VectorRetrievalStarted before the
call, …Completed (with returned_count, duration_ms) on return,
…Failed (with error) on exception — then re-raises. Document payloads
are recorded only at forensic/air_gapped capture level.
# LangChain retriever
search = record.wrap_retriever(retriever.invoke,
vector_store="qdrant", collection="docs")
docs = search("what changed in v0.63?")
# DSPy
retrieve = dspy.Retrieve(k=8)
retrieve.forward = record.wrap_retriever(retrieve.forward,
vector_store="chroma")An async variant is future design. Default-path wirings that call this API automatically (OpenAI Agents guardrail spans, LangGraph state transitions) are documented in the capture tutorial; file/memory/evaluator/reranker events have no auto-capture — they record only when you call the façade.
record.tool(fn=None, *, name=None, mutation_class="unknown", version=None, ignore=())
Status: experimental (ADR-0306 slice 1, since v0.105.0). Declares a
Python function as a tool boundary that nova capture records and mocked
nova replay serves, so the tool does not run again during replay.
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: ...
@record.tool # name = fn.__name__, mutation_class = "unknown"
async def lookup_order(order_id: str) -> dict: ...- Outside capture and replay the wrapper reads one global and calls the function; nothing is recorded.
- Under
nova capturethe body runs, then one record is written totool-calls.jsonlwithtransport: "python"; exceptions propagate unchanged and recording never raises into your code. Arguments and the result are kept only at theforensic/air_gappedcapture level (NOVA_CAPTURE_LEVEL). At the default level the record keeps the name, an argument digest and a result digest, and replay refuses it with a re-capture hint. - Under mocked
nova replaythe recorded result is returned before the body runs, matched by tool name and the arguments bound to the signature (defaults applied,ignorenames dropped), one record per call in recorded order. An unmatched call raisesReplayToolUnmatchedError; a record that cannot be served raisesReplayToolRecordNotServableErrorwith the reason. Either way the replay fails, even if your code catches the exception. With--permissivean unmatched call runs live only if a--allow-*ladder flag permits its declaredmutation_class. A capsule'sreplay.yamltool_overridesentry naming the function is enforced too (ADR-0306 slice 2):allow: falseis never run live,allow: truere-executes the body only with the ladder flag for its declared class. - Served values: JSON-native results only (
dict,list,str, numbers,bool,None), up toNOVAFABRIC_TOOL_RESULT_MAX_BYTES(default 1 MiB). Tuples, sets, pydantic models and other objects are recorded as not servable. Arguments may also be pydantic models or dataclasses (dumped to JSON); any other non-JSON argument must be listed inignore(methods needignore=("self",)). A recorded builtin exception (e.g.ValueError) is raised again as itself; any other class becomesReplayRecordedToolError. - Nested boundaries (since v0.106.0 — ADR-0306 slice 4,
experimental): a decorated function that itself calls a model, an MCP tool or
another decorated function is served like any other; the records it wrote at
capture (marked
io.novafabric.within_tool_call_id) are consumed as covered (replay_contract.model_calls_covered/tool_calls_covered), so the calls after it still get their own recorded answers. A nested call made from a raw thread is not marked; its record stays unconsumed and the replay fails closed. Capsules captured by v0.105.0 are served too, without re-capture. - Not supported: generator functions (rejected at decoration).
- Digests and secrets. At the default capture level the record keeps an
argument digest (and, for results up to 64 KiB, a result digest) instead of the
values. Both are computed after the capsule's secret rules have masked every
detected secret as
[REDACTED:<rule>], so a detected secret leaves no digest derived from its value; replay redacts the live arguments the same way before matching. ⚠ A secret the rules do not detect — a short password, a PIN, a customer number — still feeds the digest, and a digest of a low-entropy value can be confirmed offline by guessing. Do not decorate a tool that takes credentials or other low-entropy secrets at the default capture level; pass them through anignore=parameter or leave the function undecorated. Redaction costs roughly 150 µs per KiB of argument text per call at that level. MCPcall_toolarguments are matched the same way since slice 4 (v0.106.0). - Undeclared functions are not served, but since slice 4 (v0.106.0,
experimental) mocked replay compares the result such a function sends
back to the model (the
role: "tool"message, or an Anthropictool_resultblock) with the recorded one, bytool_call_id, and reports a difference underreplay_contract.tool_result_echo— report-only, it never fails the replay.
Replay
from novafabric.replay import ReplayEngine, ReplayFlags
from novafabric.replay._result import ReplayResultReplay re-executes or inspects a capsule with external calls controlled. A
replay writes a replay_result.yaml report, not a capsule — only
intervention mode also writes a minimal counterfactual Run Capsule
(capsule.yaml marked replay_mode: intervention plus the model/tool call
streams) that you can nova diff against its source.
ReplayFlags
Controls replay mode and safety settings.
@dataclass
class ReplayFlags:
mode: Literal["mocked", "forensic", "semantic", "exact", "intervention"] = "mocked"
dry_run: bool = False
allow_readonly: bool = False
allow_mutating: bool = False
allow_external_side_effects: bool = False
allow_unknown_mutation: bool = False
output_dir: Path | None = None
intervention_file: Path | None = None
required_environment: str | None = None
permissive: bool = False # mocked mode only (ADR-0300): warn instead of failModes
| Mode | What it does | Typical use |
|---|---|---|
forensic |
Read-only inspection. No subprocess, no network. | Audit / post-incident |
mocked |
Re-spawns the command (Python workloads). Serves recorded responses for OpenAI chat.completions.create and responses.create / Anthropic messages.create — sync or async, streamed or not (ADR-0304) — and recorded MCP ClientSession.call_tool results, one per call, plus — experimental — results of functions declared with record.tool and of Google ADK tools run with novafabric.adapters.google_adk.make_tool_plugin() and OpenAI Agents function tools passed through novafabric.adapters.openai_agents.wrap_tools() (both shipped in v0.106.0; the latter relies on an undocumented SDK seam). Other tools run live (HTTP, shell, files, other framework-native tools, undeclared functions); replay_contract.network_connections_live reports their connections. Fails closed (ADR-0300): an extra or unmatched call, an unsupported model surface (parse, legacy completions, with_raw_response), or an unconsumed recording makes the result failure with a divergence_reason; permissive=True only reports them. |
CI / regression |
semantic |
Does not re-execute. Scores how similar the capsule's recorded model responses are to each other (mean pairwise text similarity, 0.0–1.0); no live model is called; returns similarity_score. |
Consistency check of recorded responses |
exact |
Does not re-execute. Eligibility check for byte-exact replay (exact_eligible + reasons): deterministic env.lock, per-call seed, no tool-schema drift. |
Local / on-prem / compliance |
intervention |
Re-executes with a spec-driven intervention overlay (experimental, ADR-0086). | What-if / counterfactual analysis |
NovaFabric explicitly does not claim byte-exact replay of remote LLM calls.
exactmode reports eligibility, not a guarantee.
The allow_* flags form a safety ladder over the capsule's recorded tool calls
(allow_readonly < allow_mutating < allow_external_side_effects <
allow_unknown_mutation). They drive the dry_run report, and allow_mutating
triggers an audited policy gate before the replay starts. Inside the replayed
process they gate only the intercepted tool surfaces (ADR-0306, experimental):
with permissive=True an unmatched MCP, record.tool, ADK or Agents SDK tool call runs live only if
they permit its class (MCP calls count as unknown), and a replay.yaml
allow: true override re-executes a tool only if they permit its class. A
strict replay whose replay.yaml has an allow: false override on a tool it
cannot intercept returns status: "aborted" with error["type"] == "ToolOverrideUnenforceable". Leaving them False does not
sandbox tools that mocked mode does not intercept — run such replays in a
sandbox or against test credentials.
ReplayEngine(capsule_dir, flags, base_dir=None, actor="cli")
| Parameter | Type |
|---|---|
capsule_dir |
Path |
flags |
ReplayFlags |
actor |
str — the identity the replay_mutating policy gate evaluates (subject.user, consulted when a write-permitting flag is set) and the audit log records; default "cli" |
base_dir |
Path | None — defaults to $NOVAFABRIC_HOME/replays/ (_paths.default_replays_dir(); ./.novafabric/replays/ under the current directory up to v0.106.0) |
ReplayEngine.run() → ReplayResult
Replays the capsule according to the flags. Writes replay_result.yaml in the
output directory.
from pathlib import Path
from novafabric.replay import ReplayEngine, ReplayFlags
flags = ReplayFlags(mode="forensic")
engine = ReplayEngine(
capsule_dir=Path(".novafabric/runs/01HXAY7M5JZ8R7K4P9DPBYK2WX"),
flags=flags,
)
result = engine.run()
print(result.status) # "success" | "failure" | "aborted" | "dry_run"
print(result.model_calls_mocked) # int
print(result.env_warnings) # list of {field, original, current}Example — a mocked replay gate in CI (fail-closed, ADR-0300)
result = ReplayEngine(capsule_dir=cap, flags=ReplayFlags(mode="mocked")).run()
if result.status != "success":
# e.g. "model_queue_exhausted: no recorded openai response left for call #3 ..."
raise SystemExit(result.divergence_reason or result.error)
print(result.model_calls_mocked, "of", result.model_calls_available, "model calls served")
print(result.tool_calls_mocked, "MCP tool results served;",
result.replay_contract["tool_calls_not_interceptable"], "recorded tools not intercepted")The replayed process raises a ReplayDivergenceError subclass
(novafabric.replay._errors: ReplayQueueExhaustedError,
ReplayProviderMismatchError, ReplayOrderMismatchError,
ReplayUnsupportedSurfaceError, ReplayRecordMalformedError,
ReplayToolUnmatchedError) at the call that diverged.
Example — semantic replay against a drifting remote LLM
flags = ReplayFlags(mode="semantic")
result = ReplayEngine(capsule_dir=cap, flags=flags).run()
if result.similarity_score is not None and result.similarity_score < 0.9:
print(f"semantic drift detected: {result.similarity_score:.3f}")Example — exact-replay eligibility gate for a compliance run
flags = ReplayFlags(mode="exact")
result = ReplayEngine(capsule_dir=cap, flags=flags).run()
if not result.exact_eligible:
print("not byte-exact reproducible:", result.exact_reasons)ReplayResult
@dataclass
class ReplayResult:
replay_id: str
replay_of_run_id: str
mode: str
status: str
start_time: str
end_time: str
duration_ms: int
policy_flags_used: list[str]
env_warnings: list[dict[str, str]]
model_calls_mocked: int = 0 # mocked: responses actually served
tool_calls_mocked: int = 0 # mocked: MCP results actually served
tool_calls_available: int | None = None # recorded on a servable surface (MCP)
tool_calls_recorded: int | None = None # every recorded tool call
# mocked mode, ADR-0300 (all optional)
model_calls_available: int | None = None
model_calls_unmatched: int | None = None
tool_calls_live: int | None = None
tool_calls_unmatched: int | None = None
queues_fully_consumed: bool | None = None
divergence_reason: str | None = None
replay_contract: dict | None = None # policy, surfaces, divergences, ...
exit_code: int | None = None
error: dict | None = None
# semantic mode
similarity_score: float | None = None # 0.0–1.0; set when mode="semantic"
matched_run_id: str | None = None
# intervention mode (experimental, ADR-0086)
intervention: dict | None = None
# exact mode
exact_eligible: bool | None = None # set when mode="exact"
exact_hash_count: int | None = None
exact_reasons: list[str] | None = None
# tool-call schema drift findings (ADR-0128; additive, optional)
schema_drift: list[dict] | None = None
def as_dict(self) -> dict: ...Diff
from novafabric.diff import DiffEngine, DiffReportDiffEngine structurally compares two capsules — aligning their model calls,
tool calls, environment, and outputs — and reports what changed, was added, or
was removed. This is the engine behind nova diff --assert-no-regressions.
DiffEngine.compare(capsule_a, capsule_b) → DiffReport
from pathlib import Path
from novafabric.diff import DiffEngine
report = DiffEngine().compare(
capsule_a=Path(".novafabric/runs/01HX.../"),
capsule_b=Path(".novafabric/runs/01HY.../"),
)
print(report.changed_count) # int
print(report.added_count) # int
print(report.removed_count) # int
print(report.has_changes) # bool: any changed, added or removed entry
print(report.is_complete) # bool: False if any record line was skipped as malformed
print(report.as_dict()) # serialisable dictExample — use the diff as a CI regression gate
import sys
report = DiffEngine().compare(baseline_capsule, candidate_capsule)
if not report.is_complete:
print(report.skipped_malformed_lines)
sys.exit(2) # cannot compare: a record line was unreadable
if report.has_changes:
print(report.as_dict())
sys.exit(1) # fail the build on any structural change
has_changesis the one definition of "any difference". Thenova diff --assert-no-regressionsCLI exits1on it, and the text and GitHub-annotation formatters read it too, andas_dict()carries it as a top-levelhas_changeskey — reach for the CLI directly in shell-based pipelines. Gating onchanged_countalone misses a call that was added or removed.
has_changesis computed over the records that parsed. A non-blank line ofmodel-calls.jsonlortool-calls.jsonlthat is not UTF-8, not JSON, or not a JSON object is skipped and counted inskipped_malformed_lines(per sidea/b, per filemodel_calls/tool_calls; also a top-level key ofas_dict()), andis_completeturns False. The CLI gate then exits2, "cannot compare", before it looks athas_changes(ADR-0303 Amendment 1).
DiffReport
@dataclass
class DiffReport:
run_a_id: str
run_b_id: str
env_changes: list[dict]
model_call_pairs: list[dict]
tool_call_pairs: list[dict]
output_changes: list[dict]
skipped_malformed_lines: dict[str, dict[str, int]] # {"a"|"b": {"model_calls", "tool_calls"}}
@property
def changed_count(self) -> int: ...
@property
def added_count(self) -> int: ...
@property
def removed_count(self) -> int: ...
@property
def has_changes(self) -> bool: ...
@property
def malformed_line_count(self) -> int: ...
@property
def is_complete(self) -> bool: ...
def as_dict(self) -> dict: ...
def write(self, output_path: Path) -> None: ...Format helpers
from novafabric.diff._format import format_text, format_json, format_github_annotations
text = format_text(report) # human-readable diff
json_str = format_json(report) # machine-readable
annotations = format_github_annotations(report) # GitHub Actions annotationsLineage
from novafabric.lineage import (
LineageWriter,
LineageStore,
import_capsule_dir,
index_capsule_lineage,
ImportResult,
)Lineage is a directed provenance graph in SQLite — a rebuildable cache derived
from each capsule's lineage.jsonl. Use LineageWriter to infer edges,
index_capsule_lineage (one capsule) or import_capsule_dir (a directory of
capsules) to index them, and LineageStore to query the graph.
LineageWriter(capsule_dir, run_id, version=None)
Infers lineage edges from a capsule and writes lineage.jsonl. Each edge's
emitter.version is version, defaulting to the installed novafabric.__version__.
produced_by edges are read from the manifest's outputs — Artifact objects (keyed by
their path, with name/content_hash/media_type carried into artifact_ref; an
inline Artifact is keyed inline:<name>) or legacy path strings — and from produced.jsonl.
from pathlib import Path
from novafabric.lineage import LineageWriter
writer = LineageWriter(
capsule_dir=Path(".novafabric/runs/01HX.../"),
run_id="01HXAY7M5JZ8R7K4P9DPBYK2WX",
)
edges = writer.infer() # list[LineageEdge]
path = writer.write(edges) # writes lineage.jsonl, returns PathLineageStore(db_path=None)
SQLite-backed graph store. Default: $NOVAFABRIC_HOME/registry.db (~/.novafabric/registry.db
unless NOVAFABRIC_HOME is set; NOVAFABRIC_DB_PATH overrides the file).
from novafabric.lineage import LineageStore
store = LineageStore()
# What did this run depend on? (ancestors)
# A run node's ref is the bare run id; pass kind="run" to disambiguate.
ancestors = store.provenance(
ref="01HXAY7M5JZ8R7K4P9DPBYK2WX",
kind="run",
depth=3,
)
# What runs consume this asset? (descendants / impact)
# An asset node's ref is "<registry>:<name>@<version>" (registry "local" by default).
dependents = store.blast_radius(
ref="local:my-dataset@1.0.0",
kind="asset",
depth=5,
)
# Replay ancestry
chain = store.replay_chain(run_id="01HXREPLAY...")Each result item is a dict: {"kind": "run|asset|artifact", "ref": "..."}.
A time_travel query is also available for point-in-time graph reconstruction.
index_capsule_lineage(capsule_dir, db_path=None, *, run_id=None) → ImportResult
Index one capsule's lineage.jsonl into the graph store. The run is keyed by
run_id when given, else by the directory name (a nova capture capsule
directory is named after its run id). A capsule without lineage.jsonl returns
skipped=True; a lineage.jsonl that is present but unreadable (a symlink,
FIFO or directory) raises instead of reporting zero edges.
from pathlib import Path
from novafabric.lineage import index_capsule_lineage
result = index_capsule_lineage(Path("~/.novafabric/capsules/01HX...").expanduser())
print(result.edges_indexed) # int
print(result.nodes_indexed) # intimport_capsule_dir(runs_dir, db_path=None) → list[ImportResult]
Index every capsule directly under runs_dir (each subdirectory that holds a
capsule.yaml), returning one ImportResult per capsule.
from pathlib import Path
from novafabric.lineage import import_capsule_dir
for result in import_capsule_dir(Path("~/.novafabric/capsules").expanduser()):
print(result.capsule_run_id, result.edges_indexed, result.skipped)ImportResult
@dataclass
class ImportResult:
capsule_run_id: str
edges_indexed: int
nodes_indexed: int
skipped: bool
warning: str | None = NoneThe lineage store also emits OpenLineage 2.0.2 START/COMPLETE/FAIL events to a configured backend (Marquez, Atlan, OpenMetadata). See the CLI reference for
nova lineageemission configuration.
Graph analytics (experimental — ADR-0212..0215)
from novafabric.lineage._store import LineageStore
from novafabric.lineage.analytics import (
build_insights_report, # ADR-0215 synthesized report
compute_graph_metrics, # ADR-0212 hubs / articulation points
rank_root_causes, # ADR-0213 upstream suspect ranking
to_cypher, to_gexf, to_graphml, # ADR-0214 exports
)
store = LineageStore()
metrics = compute_graph_metrics(store, top_n=10)
report = build_insights_report(store)
graphml = to_graphml(store.all_nodes(), store.all_edges())Read-only and deterministic; oversize graphs raise LineageGraphTooLargeError
instead of truncating. See the CLI reference for the command equivalents
(nova lineage metrics|root-cause|export-graph, nova insights).
Asset Registry
from novafabric.registry.service import (
register_asset,
list_assets,
get_asset,
promote_asset,
promote_asset_direct,
)
from novafabric.spec.models import AssetStatusThe registry is a local SQLite store ($NOVAFABRIC_HOME/registry.db, overridable with
NOVAFABRIC_DB_PATH) of versioned
assets addressed as name@version. Promotion is governance metadata only —
it updates one DB record and does not deploy, restart, or redeploy anything.
register_asset(spec, spec_path, db_path=None, *, actor="") → dict
from pathlib import Path
from novafabric.spec.validator import validate_spec
from novafabric.registry.service import register_asset
spec = validate_spec(Path("my-model.yaml"))
asset = register_asset(spec, spec_path=Path("my-model.yaml"))
print(asset["id"]) # UUIDactor is the identity recorded for the registration in the audit trail.
Raises DuplicateAssetError if name@version already exists.
list_assets(asset_type, status, db_path=None) → list[dict]
asset_type and status are required positional-or-keyword arguments with no
default; pass None to leave a filter off.
from novafabric.registry.service import list_assets
agents = list_assets(asset_type="agent", status="production")
for a in agents:
print(a["name"], a["version"], a["status"])get_asset(name, version, db_path=None) → dict
from novafabric.registry.service import get_asset
asset = get_asset("my-model", "1.0.0")
# version is required; pass None (or "latest") for the latest registered versionRaises AssetNotFoundError if not found.
promote_asset(name, version, to_status, actor, force=False, db_path=None, significance_gate=False, ...) → dict
Advances an asset through the six-state lifecycle. The to_status argument is
an AssetStatus enum member. The remaining keyword arguments (sig_p0, sig_p1,
sig_alpha, sig_beta, sig_scores_path, sig_scores_metric) tune the opt-in
significance gate (ADR-0080).
Use
promote_asset_directfor a single-actor promotion.promote_assetis the ungated core: it runs the lifecycle,promotepolicy, eval and significance checks, but not the protected-stage gate.promote_asset_direct(name, version, to_status, actor, force=False, db_path=None, *, policy_subject=None, **gate_kwargs)is the one gate every single-actor route uses (nova promote direct,nova prompt promote, thenova servepromote endpoints,PUT /v0/assets/{id}/promote): a target inPROTECTED_STAGES(todayproduction) needs an explicit allow from thepromote_directpolicy action beforepromote_assetruns —forcedoes not bypass it and a no-OPANoopEnginedecision is not an allow (ADR-0312 D1). It raisesDirectPromotionDeniedError(aPromotionBlockedErrorsubclass) when refused.
from novafabric.registry.service import promote_asset
from novafabric.spec.models import AssetStatus
asset = promote_asset(
name="my-model",
version="1.0.0",
to_status=AssetStatus.staging,
actor="ci-bot",
)Raises:
InvalidLifecycleTransitionError— the transition is not permitted by the lifecycle state machine.PromotionBlockedError— a gate refused andforcedoes not override it: thepromotepolicy denied the promotion (any asset type), anagenthas no passing evaluation (its most recent eval must pass; withsignificance_gatethe whole recent sequence is judged instead), or the opt-in significance gate found a significant regression (agents only).
Lifecycle:
development → validated → pending_approval → staging → production → archived. Promotion ofagentassets is eval-gated; the OPA/Rego policy engine is consulted before the eval-score gate so decisions are always logged. Seenova promote --helpfor the maker-checker CLI flow.
Spec validation
from novafabric.spec.validator import validate_spec, SpecValidationError
from novafabric.spec.models import AssetType, AssetStatus, BaseAssetSpecvalidate_spec(yaml_path) → BaseAssetSpec
from pathlib import Path
from novafabric.spec.validator import validate_spec
spec = validate_spec(Path("my-agent.yaml"))
print(spec.name, spec.version, spec.asset_type)Raises SpecValidationError with a Pydantic error list and a hint string on
validation failure.
Asset type models
from novafabric.spec.models import (
AssetType, # Enum: model agent prompt tool dataset evaluation deployment
AssetStatus, # Enum: development validated pending_approval staging production archived
BaseAssetSpec,
ModelSpec,
AgentSpec,
PromptSpec,
ToolSpec,
DatasetSpec,
EvaluationSpec,
DeploymentSpec,
)AssetType has seven members (one per asset type). AssetStatus has the six
lifecycle states listed above.
Report generation
from novafabric.report.generator import generate_report
markdown = generate_report(format_="markdown")
json_str = generate_report(format_="json")generate_report(format_="markdown", db_path=None) renders the current registry
state. This is the engine behind nova report.
NovaSeal signing
from novafabric.trust.novaseal import NovaSeal, KeyConfigNovaSeal is the cryptographic signing core for capsule manifests: a DSSE
envelope (ECDSA P-256 / SHA-256), an optional RFC 3161 trusted timestamp,
and an entry appended to a SQLite-backed append-only Merkle log. The
local key profile is the default; cloud-KMS profiles (aws_kms, azure_kv,
gcp_kms) are also supported by the config loader.
Maturity — experimental. NovaSeal is present and covered by tests, but its interfaces (like the rest of the v0.x surface) may change before the v1.0 schema freeze. Timestamping is best-effort: if the TSA is unreachable, the capsule is still sealed, without a timestamp.
Sign a capsule manifest
from novafabric.trust.novaseal import NovaSeal, KeyConfig
# Instantiate with a local ECDSA P-256 key
config = KeyConfig(
profile="local",
key_path="~/.novafabric/seal.key",
cert_path="~/.novafabric/seal.crt",
)
seal = NovaSeal(
config=config,
tsa_url="", # "" = no RFC 3161 timestamp, no network call; or your TSA's URL
db_path="~/.novafabric/novaseal-merkle.db",
)
# Sign a capsule manifest (dict)
bundle = seal.seal(capsule_manifest)
# bundle.dsse_envelope → bytes (DSSE JSON)
# bundle.tsr → bytes (RFC 3161 DER; b"" if TSA skipped/unavailable)
# bundle.log_entry → dict (leaf_index, leaf_hash, root_hash, tree_size,
# root_version, inclusion_proof, entry)
# root_version 2 = RFC 6962 root (new logs); absent or 1 =
# legacy root, meaningful only with tree_size (ADR-0313)
# bundle.capsule_id → str (SHA-256 hex of the signed payload)A leading ~ in key_path, cert_path and a file db_path is expanded to the home directory
(MerkleLog and open_merkle_log expand it too). A Postgres DSN db_path is used exactly as
given. Up to v0.106.0 NovaSeal(...) and MerkleLog(...) did not expand ~, so the example above
created a literal ./~ directory in the working directory.
seal() accepts an optional intent (a SigningIntent, default
SigningIntent.AUTHORED) recorded in the DSSE envelope to support FDA 21 CFR
§11.50 signing-intent semantics. Pass intent=None to omit it.
Verify an existing .seal/ directory
result = seal.verify(
capsule_id=bundle.capsule_id,
seal_dir=".novafabric/runs/01HX.../.seal",
)
# result.valid → bool
# result.signature_ok → bool
# result.timestamp_ok → bool
# result.log_integrity_ok → bool (True only when inclusion was actually checked)
# result.log_inclusion → "local-log" | "carried-proof" | "not-checked" | "failed"
# result.pae_encoding → "dsse-v1" | "legacy-le64" (sealed through v0.102.x)
# result.ca_chain_ok → bool
# result.errors → list[str]
assert result.valid, str(result)
# Independent verification — no key, no config, no Merkle log needed:
from novafabric.trust.novaseal import verify_seal_dir
result = verify_seal_dir(".novafabric/runs/01HX.../.seal") # merkle_log=Nonelog_inclusion is not-checked (and valid stays True) when the capsule carries no
inclusion proof and no log holding its entry was supplied — a capsule sealed by v0.102.x
or earlier, verified away from the sealer's log. Any inconsistency (an entry that names
another capsule, a proof that does not recompute its root) is failed.
seal.verify() passes the sealer's own log, and verify_seal_dir(..., merkle_log=log)
any log you open. The log is checked at the index log-entry.json records, so a manifest
sealed twice (the same leaf twice in the log) verifies at each index. When the supplied log
holds other seals by the same signer (the entry's keyid, or the SHA-256 fingerprint of the
certificate of any signature that verifies) under the record's root version, it is that
sealer's log: an entry
missing from it is failed, even if the carried proof verifies. A log with no seals by that
signer, a fresh log, or a log of the other root version (after starting a new v2 log,
ADR-0313) is not the sealer's, and absence there keeps carried-proof, with a note in
log_notes.
timestamp_ok is False for a token that cannot be cryptographically verified. A token
the strict DER parser rejects is checked on a lenient-encoding path (timestamp_strict
False) that still requires a verifying TSA signature, a signed messageDigest equal to the
hash of the TSTInfo, and that TSTInfo's imprint equal to the envelope hash.
verify() reads manifest.dsse, an optional manifest.dsse.tsr, and
log-entry.json from the .seal/ directory. An absent or empty TSR is
reported as absent — timestamp_ok=None (ADR-0301; it was True before) — and does
not invalidate the seal; a present but invalid TSR fails verification
(timestamp_ok=False). result.identity_trust is "self-asserted" when the
signature verifies (no CA bundle is consulted here) and "none" otherwise;
result.signer_subject is the embedded certificate's subject, an unverified claim.
result.ca_chain_ok is False unless a CA bundle validated the chain
(nova verify --ca-bundle).
Rotate the signing key
from novafabric.trust.novaseal import KeyConfig
new_config = KeyConfig(profile="local", key_path="new.key", cert_path="new.crt")
receipt = seal.rotate_key(new_config)
# receipt.old_key_fingerprint, receipt.new_key_fingerprint, receipt.rotation_log_entryRotation appends a key_rotation event to the Merkle log and switches
subsequent seals to the new key.
Load a signing profile from config
Configuration is read from NOVAFABRIC_SEAL_CONFIG when set (a missing file is an
error), else from $NOVAFABRIC_HOME/novaseal.yaml (~/.novafabric/novaseal.yaml by
default):
from novafabric.trust.novaseal.config import load_signing_profile
from novafabric.trust.novaseal import NovaSeal, KeyConfig
profile = load_signing_profile() # None if NovaSeal is not configured
if profile:
seal = NovaSeal(
config=KeyConfig(
profile=profile.profile,
key_path=str(profile.key_path),
cert_path=str(profile.cert_path),
),
tsa_url=profile.tsa_url,
db_path=str(profile.merkle_db),
)The dedicated, hardened NovaSeal signing service (network service, qualified timestamps, Sigstore-keyless mode) described in ADR-0041 is a planned capability. The
novafabric.trust.novaseallibrary documented here is the in-process signing core.
Score submission
Maturity: experimental (ADR-0119). The documented, supported way for an external
evaluation pipeline — a CI job, a third-party LLM-as-judge, a human tool — to submit an
already-computed score into a capsule's append-only scores.jsonl. Works offline, no
server, no model call.
novafabric.scores.submit(capsule_dir, *, name, value, value_type, evaluator_id, subject, ...) → SubmitResult
from novafabric.scores import submit
result = submit(
"./capsules/run-01HX...",
name="answer_correct", value=True, value_type="boolean",
source="judge", evaluator_id="ci://acme/repo#judge@v3",
subject="sha256:...", # must exist in the target capsule
eval_card_digest="sha256:...", # required — the eval card reproducibility key
score_id=None, # supply a client-minted ULID for idempotency
supersedes=None, # prior score_id being corrected (append-only)
)
result.score.score_id # the appended (or replayed) evidence-grade Score
result.idempotent_replay # True → score_id already existed; no second line appended
result.config_bound # True → an ADR-0117 ScoreConfig validated the valueRemaining keyword-only parameters: source ("code" default; human /
heuristic / judge), subject_kind ("span" default), run_id,
significance (an ADR-0080 SignificanceBlock), and db_path (registry
override for score-config lookup).
Validation is fail-closed — on any rejection nothing is written, and a named
exception is raised (SubmissionInvalidError, CapsuleNotFoundError,
SubjectNotFoundError, SupersedesNotFoundError, IdempotencyConflictError,
ScoreConfigViolation — all importable from novafabric.scores). Corrections never
edit a prior record: submit a new score whose supersedes names the prior
score_id; both lines stay in the log. CLI equivalent: nova score submit.
REST client
Maturity: experimental (ADR-0202 P1, shipped 2026-07-24). A typed, sync
httpx client for the multi-tenant nova server REST API (/v0,
api/openapi.yaml). This is server-mode tooling: every local-first
feature above works without it, and importing it performs no I/O — the client
sends no request other than the ones you invoke (no telemetry, no update
checks). It mirrors the TypeScript SDK's contract (ADR-0194) and adds one
operation TS does not have yet: capsule upload.
novafabric.client.NovaFabricClient(base_url, *, api_key=None, token=None, timeout=None, retries=None, transport=None)
from pathlib import Path
from novafabric.capture import new_ulid
from novafabric.client import NovaFabricClient
with NovaFabricClient(
"https://nova.example.com/v0", # no default URL — include the /v0 prefix
api_key="nvfk_...", # or token="..." / token=lambda: fresh_jwt()
) as nc:
print(nc.health().data.version) # server version (skew diagnostics)
# Capsules: upload a directory (packed to a deterministic ZIP), a .zip
# path, or raw ZIP bytes; then read back.
nc.upload_capsule(Path("./capsules/run-01HX..."))
detail = nc.get_capsule("run-01HX...").data
# Cursor pagination: one page, or a lazy iterator over all pages.
page = nc.list_capsules(limit=100).data # .items / .next_cursor / .total
for capsule in nc.iter_capsules(): # one HTTP call per page
print(capsule.run_id, capsule.status)
# Assets mirror the same surface: get_asset / list_assets / iter_assets.
# External scores (ADR-0119): 201 → stored record; a 200 idempotent
# replay carries no body, so .data is None — inspect .meta.status.
result = nc.submit_score("run-01HX...", {
"name": "answer_correct", "value": True, "value_type": "boolean",
"source": "judge", "evaluator_id": "ci://acme/repo#judge@v3",
"subject": "sha256:...", "eval_card_digest": "sha256:...",
"score_id": new_ulid(), # client-minted ULID → safe to re-send
})Contract points (normative spec: design/spec/python-client-v0.md (private)):
- Config resolution — constructor argument → environment variable
(
NOVAFABRIC_SERVER_URL,NOVAFABRIC_API_KEY,NOVAFABRIC_TOKEN) → error. A missing base URL raisesNovaFabricConfigErrorat construction; so does passing bothapi_keyandtoken. If both credential env vars are set,NOVAFABRIC_API_KEYwins with aUserWarning. - Auth — exactly one bearer header.
api_keytakes annvfk_key (ADR-0193);tokentakes an OIDC/offline/local token, as a string or a zero-arg callable your application refreshes. No OIDC flow is performed. - Results — every call returns
ApiResult(data, meta);metacarriesstatus, RFC 9745Deprecation/ RFC 8594Sunset(also emitted once per endpoint as aDeprecationWarning; reset withnovafabric.client.reset_deprecation_warnings()), theX-NovaFabric-Quota-Warningvalue, and a request id when the server sends one. Models are Pydantic v2 withextra="allow"— additive server fields never break an older client. - Errors — every non-2xx raises a typed subclass of
NovaFabricAPIError(AuthenticationError401,AuthorizationError403,NotFoundError404,ConflictError409,PreconditionFailedError412,ValidationFailedError422,RateLimitedError429 with.retry_after,ServerError5xx) carrying the envelopecodeverbatim, with anunknown_errorfallback for non-envelope bodies. Transport failures raiseNovaFabricTransportError(NovaFabricTimeoutfor timeouts) — never an API error. - Retries — bounded, GET-only (no POST is ever auto-retried), on
connect errors and 429/502/503/504, honoring
Retry-After(capped 30 s); tune or disable viaRetryConfig(max_attempts=1disables). Default timeouts: connect 5 s, read/write 30 s, pool 5 s. - Pagination — cursors are opaque strings;
iter_*fetches lazily, one page ahead at most, and terminates onnext_cursor: null. - Testing seam — pass any
httpx.BaseTransportastransport=(e.g.httpx.MockTransport) for hermetic tests.
An async twin (AsyncNovaFabricClient) and artifact streaming are planned
(ADR-0202 P2); SSE and an OTLP endpoint helper are future design (P3).
Compliance-export provenance (experimental — ADR-0197)
Field-group compliance exporters tag each field-group with an evidence_source
marker so a consumer can tell how NovaFabric established each value. The shared
primitive lives in novafabric.compliance.export.provenance:
from novafabric.compliance.export.provenance import (
EvidenceSource, # operator_asserted | capsule_verified | unverifiable
EvidenceSourceRef, # re-performable ref: capsule_id + content_digest (+ seal path)
build_capsule_ref, # hash a capsule dir into an EvidenceSourceRef
mark, # validate a (source, ref) pairing
validate_marked, # assert a set of field-groups is honestly marked
)
# capsule_verified MUST carry a re-performable reference (ADR-0197 I-3):
ref = build_capsule_ref(capsule_dir, verified_at="2026-07-24T00:00:00Z")
source, ref = mark(EvidenceSource.capsule_verified, ref=ref)
# Enforce on an export path — raises UnmarkedFieldGroupError / MissingReperformableRefError:
from novafabric.compliance.export import AnnexIVExporter
doc = AnnexIVExporter().build_annex_iv_document("dep-1", capsule_dir)
validate_marked(doc.completeness_summary())operator_asserted and unverifiable must not carry a ref (a reference
would misrepresent them as a re-performed verification). The marker is additive
and optional on the wire — a pre-ADR-0197 document deserializes with
evidence_source is None. See design/spec/evidence-source-provenance-marker.md (private).
Utility
from novafabric.capture import new_ulid, new_span_id
run_id = new_ulid() # str — ULID (26 chars, time-sortable)
span_id = new_span_id() # str — 16-char hexSummary and next steps
| You want to… | Use | CLI equivalent |
|---|---|---|
| Capture one function in-process | @agent decorator |
— |
| Capture an arbitrary command | CaptureOrchestrator.run |
nova capture |
| Re-run or inspect a capsule | ReplayEngine + ReplayFlags |
nova replay |
| Compare two runs / gate CI | DiffEngine.compare |
nova diff --assert-no-regressions |
| Trace provenance / blast radius | LineageStore |
nova lineage |
| Register / promote assets | registry.service |
nova register / nova promote |
| Validate a spec | validate_spec |
nova validate |
| Sign / verify a capsule | NovaSeal |
nova seal / nova verify |
| Talk to a NovaFabric server from Python | NovaFabricClient (experimental) |
— |
Where to go next
- CLI reference (
docs/cli-reference.md) — everynovasubcommand, flag, and default. Each Python API above has a CLI twin; when in doubt, runnova <command> --help. - Developer guide (
docs/developer-guide.md) — how to add asset types, CLI commands and report formats. Third-party capture hooks (experimental) register aHookProtocolclass under thenovafabric.hooksentry-point group; the developer guide does not cover them yet — the contract is thenovafabric.capture.hooks._pluginmodule docstring (RFC-0001). - The five primitives — Asset Registry, Run Capsule, Replay, Lineage, and Evidence Bundle. Each has a public JSON Schema; the capsule is the source of truth and the registry/lineage graph are rebuildable indexes derived from it.