---
title: "NovaSeal Configuration Reference"
description: "This document is the single source of truth for configuring NovaSeal: the novaseal.yaml config file, all environment variables, path resolution order…"
canonical: https://novafabric.ai/docs/novaseal-configuration/
last_updated: 2026-10-23
source: https://github.com/MSKazemi/novafabric/blob/main/docs/novaseal-configuration.md
---

# NovaSeal Configuration Reference

**Status:** Works today (NovaSeal v0.1+, ADR-0041)

This document is the single source of truth for configuring NovaSeal: the
`novaseal.yaml` config file, all environment variables, path resolution order,
and deployment-specific patterns.

Related documents:
- [NovaSeal Key Management Guide](https://novafabric.ai/docs/novaseal-key-management/) — key generation, rotation, HSM setup
- [NovaSeal API Stability Guarantee](https://novafabric.ai/docs/novaseal-stability/) — what changes are breaking
- [Operator Guide](https://novafabric.ai/docs/operator-guide/) — cluster and Docker deployment context
- [CLI Reference § nova seal / nova verify](https://novafabric.ai/docs/cli-reference/registry/#novaseal-linked-envelope-chain-maker-checker-v0140-adr-0059)

---

## Table of contents

1. [Configuration file — `novaseal.yaml`](#1-configuration-file--novasealyaml)
   - 1.1 [TSA fallback list (`tsa_urls`)](#11-tsa-fallback-list-tsa_urls)
   - 1.2 [Generated by `nova seal init` (local, self-asserted)](#12-generated-by-nova-seal-init-local-self-asserted)
2. [Profiles](#2-profiles)
   - 2.1 [local — ECDSA P-256 file key](#21-local--ecdsa-p-256-file-key)
   - 2.2 [aws_kms — AWS KMS asymmetric key](#22-aws_kms--aws-kms-asymmetric-key)
   - 2.3 [azure_kv — Azure Key Vault](#23-azure_kv--azure-key-vault)
   - 2.4 [gcp_kms — GCP Cloud KMS](#24-gcp_kms--gcp-cloud-kms)
3. [Merkle log database (`merkle_db`)](#3-merkle-log-database-merkle_db)
   - 3.1 [Path resolution order](#31-path-resolution-order)
   - 3.2 [Docker and container deployments](#32-docker-and-container-deployments)
   - 3.3 [SLURM and shared-filesystem deployments](#33-slurm-and-shared-filesystem-deployments)
   - 3.4 [Postgres backend (experimental, Scale-S4)](#34-postgres-backend-experimental-scale-s4)
4. [Environment variables — complete reference](#4-environment-variables--complete-reference)
5. [Discovery order for `novaseal.yaml`](#5-discovery-order-for-novasealyaml)
6. [Troubleshooting](#6-troubleshooting)

---

## 1. Configuration file — `novaseal.yaml`

NovaSeal is **opt-in**: if no `novaseal.yaml` is found, NovaSeal is silently
disabled and `nova capture` proceeds without signing. This is the correct
behavior for local development. `nova init` never writes this file; the
quickest way to create one is `nova seal init` (§1.2, experimental, ADR-0301).

The file is discovered automatically (see §5). A minimal example for local
use with a file-based ECDSA P-256 key:

```yaml
# ~/.novafabric/novaseal.yaml
profile: local
key_path: ~/.novafabric/seal.key     # ECDSA P-256 private key (PEM, PKCS#8)
cert_path: ~/.novafabric/seal.crt    # X.509 certificate for the key
merkle_db: ~/.novafabric/novaseal-merkle.db  # SQLite Merkle log (optional)
# tsa_url: https://tsa.example.org/tsr  # RFC 3161 timestamp authority — opt-in
```

All path values are expanded with `~` resolution. `profile` defaults to
`local`. `cert_path` is always required, plus the profile's key reference
(`key_path` for `local`, `kms_key_id` for `aws_kms`, `vault_url` + `key_name` for
`azure_kv`, `key_version_name` for `gcp_kms`); everything else is optional.

**Where the file and the log live by default:** both defaults sit under
`$NOVAFABRIC_HOME` — `$NOVAFABRIC_HOME/novaseal.yaml` and
`$NOVAFABRIC_HOME/novaseal-merkle.db`, i.e. `~/.novafabric/…` only when
`NOVAFABRIC_HOME` is unset. If `NOVAFABRIC_HOME` points elsewhere, a
`~/.novafabric/novaseal.yaml` is **not** read and sealing is silently off.

**RFC 3161 timestamping is opt-in** (since v0.103, ADR-0292). With no `tsa_url`
(or `tsa_urls`) NovaSeal signs and logs every capsule but requests **no**
timestamp and makes **no** network call; it logs one warning per process saying
so, and `nova verify` reports `Timestamp (RFC 3161): NOT PRESENT`. To timestamp,
name a TSA explicitly — your organisation's or a QTSP's. The public FreeTSA
service (`https://freetsa.org/tsr`) works for development and demos; it is
rate-limited, not eIDAS-qualified, and never contacted unless you configure it.

> **Behaviour change in v0.103.** Through v0.102.x an omitted `tsa_url` silently
> meant `https://freetsa.org/tsr`. To keep timestamping exactly as before, add
> `tsa_url: https://freetsa.org/tsr` to `novaseal.yaml`.

### 1.1 TSA fallback list (`tsa_urls`)

**Status:** Works today (REG-ADR-007). When only `tsa_url` is set it is the
only TSA tried; a `tsa_urls` list on its own also opts in to timestamping. For production and EU-regulated deployments where a single TSA being
unreachable should not block sealing, configure an ordered fallback list
instead:

```yaml
# ~/.novafabric/novaseal.yaml
profile: local
key_path: ~/.novafabric/seal.key
cert_path: ~/.novafabric/seal.crt
tsa_urls:                                    # tried in order; first success wins
  - https://tsa.your-qtsp.example/tsr        # primary — e.g. a QTSP for eIDAS Art. 41(2)
  - https://freetsa.org/tsr                  # fallback — public, non-qualified (example)
merkle_db: ~/.novafabric/novaseal-merkle.db
```

If `tsa_urls` is omitted, it defaults to a single-entry list containing
`tsa_url` — existing configs with only `tsa_url` set need no changes. When
`tsa_urls` has more than one entry, each is tried in sequence; a TSA that
returns an error, times out, or is unreachable is skipped and the next one
is tried. Timestamping is already best-effort in NovaSeal (a capsule seals
successfully without a timestamp token if the TSA request fails) — the
fallback list only widens how many TSAs must fail before that happens.

`tsa_urls` applies identically across all four profiles (§2) — it only
affects the timestamp request, not the signing key/certificate.

**Not yet built:** fallback is sequential with no configurable per-TSA
timeout or retry count (each URL gets one attempt at the module's fixed
30-second HTTP timeout before moving to the next) — sufficient for a small
fallback list, but there is no automated benchmark validating this against
a specific deployment's real network conditions (e.g. an air-gapped site
with a local relay TSA); that assessment is left to the operator.

### `ca_bundle` — signer certificate chain validation (ADR-0055)

**Status:** experimental. An optional key, accepted by all four profiles:

```yaml
ca_bundle: /etc/novaseal/ca-bundle.crt   # operator CA chain (PEM, concatenated)
```

When set, `nova verify` adds a `Signer certificate chain (CA bundle)` check:
the certificate embedded in the capsule's DSSE envelope must chain, offline,
to a certificate in the bundle (every bundle certificate is a trust anchor,
so include the issuing intermediate). `nova verify --ca-bundle PATH`
overrides the key for one run. A configured path that does not exist is a
config error — it never silently disables the check. Omit the key and
verification is unchanged. Validity is evaluated at the current time; no OCSP.
Details: [NovaSeal Key Management
§2.3](https://novafabric.ai/docs/novaseal-key-management/#23-x509-cert-pinned-identity-adr-0055--works-today-library-api-only).

### `crl_dir` / `crl_strict` — offline CRL revocation check (ADR-0070 §3)

**Status:** experimental. Optional, all four profiles; only takes effect
together with a CA bundle (`ca_bundle` here or `nova verify --ca-bundle`):

```yaml
crl_dir: /var/lib/novaseal/crl   # CRLs (DER or PEM) synced by an operator cron job
crl_strict: false                # true: a missing / stale / invalid CRL also fails
```

`nova verify` then checks every certificate of the validated signer chain
against the locally synced CRLs — nothing is ever fetched. A revoked
certificate always fails; by default a missing, stale or unverifiable CRL is
a visible warning (soft-fail, ADR-0070 §3), and `crl_strict: true` (or
`--crl-strict`) turns those into failures. `--crl-dir DIR` overrides
`crl_dir`; `--crl-strict` can only tighten the config value. A `crl_dir` that
does not exist, an empty string, a non-boolean `crl_strict`, or `crl_dir` /
`crl_strict` set without `ca_bundle` (which would check nothing) is a config
error (unless `tsa_ca_certs` is set — the CRLs then apply to the TSA
chain). Directory limits, statuses and scope rules: [NovaSeal Key Management
§2.3](https://novafabric.ai/docs/novaseal-key-management/#23-x509-cert-pinned-identity-adr-0055--works-today-library-api-only).

### `tsa_ca_certs` — RFC 3161 TSA trust chain (ADR-0070 §1/§5)

**Status:** experimental. Optional, all four profiles — a list of PEM files
holding the TSA CA certificates the operator trusts (NIST SP 800-89:
pre-provisioned anchors; no public bundle is shipped or downloaded):

```yaml
tsa_ca_certs:
  - /etc/novaseal/tsa/freetsa-cacert.pem   # e.g. https://freetsa.org/files/cacert.pem
```

When set and a capsule carries `manifest.dsse.tsr`, `nova verify` adds a
`TSA certificate chain (RFC 3161, TSA CA bundle)` check: the token's CMS
signature, the TSA certificate's critical `id-kp-timeStamping`-only EKU, the
ESSCertID binding, the message imprint (SHA-256 of the DSSE envelope) and
the chain to these anchors at the token's `genTime`, all offline. A capsule
with no (or an empty) token then **fails** — the token is outside the DSSE
signature, so its absence cannot be told apart from its deletion. `crl_dir` /
`crl_strict` also apply to this chain (revocation as of `genTime`, CRL
freshness judged at verification time). `nova verify --tsa-ca-bundle PATH`
overrides the key for one run. A listed file that does not exist, an empty
list, a non-list value, or more than 32 entries is a config error. Omit the
key and verification is unchanged. Details: [NovaSeal Key Management
§2.5](https://novafabric.ai/docs/novaseal-key-management/#25-rfc-3161-tsa-trust-chain-adr-0070-1--experimental).

Without this key (or `--tsa-ca-bundle`) a timestamp is never trusted time: a
verified token is reported `UNANCHORED` (`timestamp_trust: unanchored`), because
anyone can mint a TSA certificate and sign any `genTime`. There is no built-in
default TSA trust bundle, and `tsa_url` anchors nothing — it only names where seals
request tokens. With the key set, `POST /api/runs/{id}/verify`, the Evidence
Bundle verify endpoint and the trust radar anchor the token too.

The ADR's `crl_cache_dir` key is served by the existing `crl_dir` (one
operator-synced directory for both chains); it has no default path — an
unset `crl_dir` means no revocation check, never a silently empty one.

### 1.2 Generated by `nova seal init` (local, self-asserted)

**Experimental** (ADR-0301). `nova seal init` writes a ready-to-use `local` profile
and the key material it points at, offline:

```yaml
# managed-by: nova seal init
# Local sealing identity (ADR-0301). SELF-ASSERTED: ...
profile: local
key_path: $NOVAFABRIC_HOME/keys/novaseal/signing.key.pem    # ECDSA P-256, mode 600
cert_path: $NOVAFABRIC_HOME/keys/novaseal/signing.crt.pem   # issued by the local seal CA
merkle_db: $NOVAFABRIC_HOME/novaseal-merkle.db
```

(The real file carries absolute paths.) The first line marks the file as managed:
`nova seal init --force` may archive and replace it; a file **without** that line —
an operator's KMS or CA-issued profile — is never replaced. The local seal CA is
`keys/novaseal/ca.crt.pem` (`ca.key.pem`, mode 600, beside it); both certificates
carry `O=NovaFabric local seal identity (self-asserted)`.

| Trust level `nova verify` reports | How |
|---|---|
| `self-asserted` | default — proves the key holder signed, identifies nobody |
| `local-ca-pinned` | `nova verify --ca-bundle $NOVAFABRIC_HOME/keys/novaseal/ca.crt.pem` — continuity with this installation across `--force` rotations |
| `ca-anchored` | only with a certificate from a CA the verifier trusts (§2, your own `cert_path`) |

You may add `tsa_url` (opt-in timestamps) or `ca_bundle` to the generated file; it
stays managed as long as the first line is kept. Rotation: `nova seal init --force`
(new key, same CA, old files archived to `keys/novaseal/archive/<UTC>/`, a
`key_rotation` entry in the Merkle log); `--force --new-ca` also replaces the CA.

---

## 2. Profiles

The four profiles below are configured through `novaseal.yaml`'s `profile:`
field. Two further signing backends exist outside this file, selected
instead via a CLI flag on `nova seal sign`/`nova verify` — a keyless
`--backend sigstore` (ADR-0071) and the ADR-0055 `x509` cert-pinned identity
(currently a library API, not yet a CLI flag); see [NovaSeal Key Management
§2.3–2.4](https://novafabric.ai/docs/novaseal-key-management/#23-x509-cert-pinned-identity-adr-0055--works-today-library-api-only)
for both.

### 2.1 `local` — ECDSA P-256 file key

The default for on-premises and laptop deployments. The private key is an
**unencrypted** PEM file (PKCS#8) on disk — there is no keychain or
secrets-manager integration, so protect it with file permissions (0600) and disk
encryption. ECDSA P-256 is the default (what `nova seal init` generates); an
Ed25519 key with a matching certificate is also accepted by the `local` profile.

```yaml
profile: local
key_path: /path/to/seal.pem      # required — unencrypted PKCS#8 PEM, ECDSA P-256 (or Ed25519)
cert_path: /path/to/seal.crt     # required — PEM certificate
tsa_url: https://tsa.example.org/tsr  # optional, opt-in; omitted = no timestamp, no network call
merkle_db: /path/to/merkle.db    # optional; see §3
```

`nova seal init` (§1.2) generates a key, a local CA and a CA-issued certificate for
this profile in one step. To bring your own instead, generate a key and self-signed
certificate (`nova verify` reports a self-signed signer as `self-asserted`, and
`--ca-bundle` cannot pin a self-signed end-entity certificate):

```bash
openssl ecparam -name prime256v1 -genkey -noout | \
  openssl pkcs8 -topk8 -nocrypt -out ~/.novafabric/seal.key

openssl req -new -x509 -key ~/.novafabric/seal.key \
  -out ~/.novafabric/seal.crt \
  -days 3650 -subj "/CN=NovaSeal-Local"
```

### 2.2 `aws_kms` — AWS KMS asymmetric key

Uses an AWS KMS ECDSA_SHA_256 key. Requires the `[seal-aws]` optional extra:
`pip install novafabric[seal-aws]`.

```yaml
profile: aws_kms
kms_key_id: arn:aws:kms:us-east-1:123456789012:key/mrk-abc123  # required
aws_region: us-east-1             # optional; default us-east-1
cert_path: /path/to/cert.pem      # required — certificate for the KMS public key
tsa_url: https://tsa.example.org/tsr  # optional, opt-in (no default TSA)
merkle_db: /path/to/merkle.db     # optional; see §3
```

### 2.3 `azure_kv` — Azure Key Vault

Uses an Azure Key Vault EC P-256 key. Requires `[seal-azure]`.

```yaml
profile: azure_kv
vault_url: https://myvault.vault.azure.net/  # required
key_name: my-ec-key                          # required
cert_path: /path/to/cert.pem                 # required
tsa_url: https://tsa.example.org/tsr         # optional, opt-in
merkle_db: /path/to/merkle.db               # optional; see §3
```

### 2.4 `gcp_kms` — GCP Cloud KMS

Uses a GCP Cloud KMS EC P-256 asymmetric signing key. Requires `[seal-gcp]`.

```yaml
profile: gcp_kms
key_version_name: projects/P/locations/L/keyRings/R/cryptoKeys/K/cryptoKeyVersions/1
cert_path: /path/to/cert.pem  # required
tsa_url: https://tsa.example.org/tsr  # optional, opt-in
merkle_db: /path/to/merkle.db    # optional; see §3
```

---

## 3. Merkle log database (`merkle_db`)

NovaSeal maintains a tamper-evident SQLite Merkle log of all seal operations.
The `merkle_db` field in `novaseal.yaml` controls where this file lives.

### 3.1 Path resolution order

**Works today.** Every NovaSeal caller resolves the Merkle log through one rule
(`novafabric.trust.novaseal.config.resolve_merkle_db_uri()`; the loaded profile's
`merkle_db` is computed by the same rule). That covers capture sealing,
`nova serve` sealing, `nova verify`, the trust radar and dashboard Seal tab,
`POST /api/runs/{id}/verify`, the dashboard `/api/doctor` `novaseal_db` row, the
SealTab and `nova server` policy-store endpoints, the CLI policy-store defaults
(`nova policy sign` / `list`, `nova seal propose` / `verify`, `nova
export-model-independence --policy-db`, `POST /api/policy/sign`; changed 2026-10-23,
see the troubleshooting note below), `nova seal log verify`, and the
`key_rotation` entry `nova seal init --force` appends. So the log a capsule is sealed
into is the log every verifier reads.

```mermaid
flowchart TD
    subgraph SEAL["Sealing and verification"]
        A["nova capture · serve sealing<br/>nova verify · trust radar"] --> Y["merkle_db in novaseal.yaml"]
        Y -- "not set" --> H["$NOVAFABRIC_HOME/novaseal-merkle.db"]
    end
    subgraph DIAG["Diagnostics and policy store"]
        B["nova doctor · SealTab<br/>nova seal log verify"] --> E{"NOVAFABRIC_SEAL_DB_PATH<br/>set?"}
        E -- yes --> ENV["that path or DSN"]
        E -- no --> Y2["merkle_db in novaseal.yaml"]
        Y2 -- "not set" --> H2["$NOVAFABRIC_HOME/novaseal-merkle.db"]
    end
    subgraph PROMO["Promotion workflow"]
        P["nova seal propose · approve · bypass"] --> DB["--db<br/>default ~/.local/share/novafabric/merkle.db"]
    end
```

**Precedence (highest to lowest):**

| Priority | Source | When to use |
|---|---|---|
| 1 | `NOVAFABRIC_SEAL_DB_PATH` env var | Process-wide override: CI/test environments, or a job that must seal into a specific log. A path or a Postgres DSN (§3.4) |
| 2 | `merkle_db:` field in `novaseal.yaml` | **Production config** — set this for non-default paths |
| 3 | `$NOVAFABRIC_HOME/novaseal-merkle.db` (default `~/.novafabric/novaseal-merkle.db`) | Default for local laptop use |

If `novaseal.yaml` is not found, or is not valid YAML, priority 2 is skipped and
priority 3 applies. A `merkle_db` (or `NOVAFABRIC_SEAL_DB_PATH`) that is neither a
file path nor a supported DSN is a config error (§3.4); it is never used as a file
name. Loading the config creates nothing on disk. The SQLite file and its directory
are created the first time something appends to the log.

`nova seal init` writes `merkle_db: $NOVAFABRIC_HOME/novaseal-merkle.db`.
`nova seal init --force` keeps a `merkle_db` you set in the managed file, and
records the rotation in the log this rule resolves.

Before 2026-10-22 capture sealing, `nova verify` and the radar ignored
`NOVAFABRIC_SEAL_DB_PATH`, while the SealTab and `nova seal log verify` honoured it.
With the variable set, capsules were sealed into one log and checked against
another.

### 3.2 Docker and container deployments

In Docker the default home directory and `NOVAFABRIC_HOME` are typically
different from the host. Set `merkle_db` explicitly in `novaseal.yaml` so the
path always resolves to the mounted volume:

```yaml
# /data/nova/novaseal.yaml  (mounted into the container)
profile: local
key_path: /data/nova/keys/seal.key
cert_path: /data/nova/keys/seal.crt
merkle_db: /data/nova/novaseal-merkle.db
```

```yaml
# docker-compose.yml (excerpt)
services:
  nova-serve:
    environment:
      NOVAFABRIC_SEAL_CONFIG: /data/nova/novaseal.yaml
    volumes:
      - nova-data:/data/nova
```

Setting `NOVAFABRIC_SEAL_CONFIG` ensures the config is found regardless of
where the container's home directory or `NOVAFABRIC_HOME` is.

### 3.3 SLURM and shared-filesystem deployments

⚠ **Do not put the SQLite Merkle log on a shared network filesystem** (Lustre,
GPFS, NFS) or share one log between concurrent jobs. SQLite's locking and WAL mode do
not work reliably over a network filesystem. Use one log per node (a node-local `merkle_db`), for example:

```yaml
# /home/$USER/.novafabric/novaseal.yaml
profile: local
key_path: /lustre/project/novafabric/keys/seal.key
cert_path: /lustre/project/novafabric/keys/seal.crt
merkle_db: /tmp/$USER/novafabric/novaseal-merkle.db   # node-local, one writer
```

Several processes on **one host** may append to one local SQLite log: each
append takes the write lock before it picks its leaf index, and waiters queue
on SQLite's busy timeout. Before 2026-10-22 two captures sealing at the same
moment could pick the same index, and one capsule was left unsealed with only a
warning (measured: 10 of 200 appends from 8 processes). That does not make a
network filesystem safe. Shared, concurrent appends across nodes are what the
experimental Postgres backend (§3.4) is for.

### 3.3a `previous_merkle_dbs` — moving a host to v2 roots (ADR-0313)

**Status:** experimental. A log keeps one root version for life, so moving a host
from v1 to v2 roots means starting a new `merkle_db` file. List the retired log so
verification can still check the capsules it sealed:

```yaml
merkle_db: ~/.novafabric/novaseal-merkle-v2.db
previous_merkle_dbs:
  - ~/.novafabric/novaseal-merkle.db      # the retired v1 log
```

Verification only — nothing is ever appended to a previous log. Entries are SQLite
paths or Postgres DSNs; a listed SQLite file that does not exist, a non-list value,
or more than 32 entries is a config error.

Without it, a v1 capsule verified on a host whose log (holding this signer's seals)
is v2 keeps its carried-proof result but reports `sealer_log:
unverifiable-other-version`, and `nova verify --strict` fails on it. The
`root_version` that says "v1" is a field of `log-entry.json`, which whoever wrote
the capsule controls, so a key holder's forgery claiming v1 looks exactly like a
genuine pre-migration capsule until the v1 log is consulted. A v2 capsule checked
against a v1 sealer's log fails: v2 logs came later.

### 3.4 Postgres backend (experimental, Scale-S4)

**Experimental** — implemented (ROADMAP Scale-S4). SQLite remains the default;
the Postgres backend is opt-in via a Postgres DSN. It needs the `seal-postgres`
extra (psycopg 3).

```yaml
merkle_db: postgres://user:pass@pghost/novafabric_seal
```

Accepted schemes: `postgres://`, `postgresql://`, and the SQLAlchemy spelling
`postgresql+psycopg://` (rewritten to `postgresql://`, the form psycopg connects
with). A DSN stays a string from the config to the connection. Any other
`scheme://` value — `postgresql+psycopg2://` (a driver NovaFabric does not ship),
`mysql://`, or a DSN missing a slash (`postgres:/…`) — is a `SealConfigError`, never
a file path. The `NOVAFABRIC_SEAL_DB_PATH` env var accepts the same values.

**Secrets.** NovaFabric never writes the DSN to disk (no directory or file is
created from it) and never prints its password: error messages,
`nova seal log verify` and the `/api/doctor` row show `user:***@host`. Prefer
keeping the password out of the URL altogether, in `~/.pgpass` or the `PGPASSWORD`
environment variable, which libpq reads.

Before 2026-10-22, a DSN in `merkle_db` was wrapped in a file path, which turned
`//` into `/`. Loading the config then created a directory tree named after the
DSN, password included, in the current directory. Capture sealing silently wrote
to a SQLite file in that tree instead of Postgres. If you configured a DSN before
that, look for and delete a `postgres:` or `postgresql:` directory in the
directories you ran `nova` from.

**What Postgres does and does not buy you.** It removes SQLite's single-writer
limit and its sequential table scan, which is the reason to switch at high write
volume. It does **not** make `nova seal log verify` sub-linear: the Merkle root is
recomputed from every stored leaf hash on both backends, so verification is O(N)
either way — roughly **1.9 s at 1M entries**, dominated by ~1M SHA-256 calls in
Python rather than by the database.

**Full recompute is the guarantee, and linear cost is its documented price**
([ADR-0268](https://novafabric.ai/docs/decisions/), decided 2026-10-09). A clean `nova seal log verify`
means every stored leaf hash was folded back into the recorded root — no signer
or cached interior node is trusted. *Measured:* ~1.9 s end to end at 1M entries
(GitHub-hosted runner + Postgres 16, 2026-09); 1361 ms of that is in-process
hashing with no database, and the in-process pass scales at roughly 1.2 µs per
leaf (re-measured 2026-10 on a laptop). Plan on roughly 1.2–2 s per million
entries plus the time to read the leaf hashes from the backend, and run
verification at that scale as a scheduled audit, not an interactive check.

⚠ Earlier releases documented a p99 < 200 ms verification at 1M entries. **That
figure was never achieved and is withdrawn by decision**, not deferred — see the
correction in [`docs/releases/v0.38.0.md`](https://github.com/MSKazemi/novafabric/blob/main/docs/releases/v0.38.0.md). A faster path
(checking sampled leaves against a signed tree head) would detect less and add a
trusted signer, so it is not planned.

---

## 4. Environment variables — complete reference

| Variable | Default | Description |
|---|---|---|
| `NOVAFABRIC_SEAL_CONFIG` | `$NOVAFABRIC_HOME/novaseal.yaml` | Path to `novaseal.yaml`. Takes precedence over the default location. Set this in Docker/K8s to point to the config on the mounted volume. |
| `NOVAFABRIC_SEAL_DB_PATH` | — | Explicit Merkle log path (or a Postgres DSN for the experimental Postgres backend, §3.4). Overrides `merkle_db` in `novaseal.yaml` for **every** caller — sealing, verification, the dashboard and `nova seal log verify` (§3.1). Intended for CI/test environments. |
| `NOVAFABRIC_HOME` | `~/.novafabric` | Base directory for the default `novaseal.yaml` and `novaseal-merkle.db`. |
| `NOVA_BYPASS_NOTIFY_FILE` | — | Append one JSON line per maker-checker bypass to this file (§4.1). |
| `NOVA_BYPASS_NOTIFY_WEBHOOK` | — | POST one JSON event per maker-checker bypass to this `http(s)://` URL (§4.1). |
| `NOVAFABRIC_PROMOTE_CA_BUNDLE` | — | CA bundle that `nova seal verify` trusts for maker-checker signers; without it every signer is refused. |
| `NOVAFABRIC_PROMOTE_INSECURE_SELF_SIGNED` | — | Dev-only: accept self-signed maker-checker certificates. Never in production. |
| `NOVA_REKOR_URL` | — | When set, approval and export envelopes are also published to this Rekor transparency log (network call). |
| `NOVASEAL_KMS_ENDPOINT` | — | NovaSeal KMS endpoint for collector batch signing (cluster-scale, experimental). |

**Interaction between `NOVAFABRIC_SEAL_CONFIG` and `NOVAFABRIC_SEAL_DB_PATH`:**

`NOVAFABRIC_SEAL_DB_PATH` wins over the YAML `merkle_db` for every caller (§3.1).
`nova seal init` writes `$NOVAFABRIC_HOME/novaseal.yaml` (it warns when
`NOVAFABRIC_SEAL_CONFIG` points elsewhere, ADR-0301); `nova seal init --force`
records the key rotation in the log §3.1 resolves, including the variable.

### 4.1 Maker-checker bypass notifications

**Works today.** Every maker-checker bypass — `nova seal bypass` and the dashboard's
`POST /api/seal/{capsule_id}/bypass` — is announced on the channels these variables
configure, read when the bypass is created:

| Variable | Channel |
|---|---|
| `NOVA_BYPASS_NOTIFY_FILE` | appends one JSON line (`event_type: bypass_created`, `capsule_id`, `bypass_uuid`, `valid_until`, `requester_keyid`, `recipients`, `timestamp`) |
| `NOVA_BYPASS_NOTIFY_WEBHOOK` | POSTs the same JSON object (`Content-Type: application/json`, no retry). The 5 s limit is a **total** deadline for the whole delivery, not a per-read timeout |

Set both to use both.

**A bypass is refused unless it is audited (fail closed).** A bypass skips the
two-person rule, and the audit trail is its compensating control. Before the
bypass is stored, a `seal.bypass.create` entry goes into the hash-chained audit log.
It records the actor (`cli-user`, or `serve:<token fingerprint>` for the dashboard),
the signer's certificate name (`authorized_by`), `bypass_uuid`, `valid_until`, the
SHA-256 of the signed envelope, the configured `notification_channels` and the
`surface` (`cli` or `serve`). If that entry cannot be written, the bypass is refused
and nothing is stored or announced: `nova seal bypass` exits 1, and the route answers
`503`. The dashboard route also writes a `seal_bypass` record to the dashboard audit
log (`result` `ok` or `error`).

**A bypass that cannot be stored is withdrawn (works today; review r8).** The entry is
written before the bypass file is renamed into place. If that rename fails (disk full,
I/O error), the staging file is removed, a `seal.bypass.abort` entry with the same
`bypass_uuid` (`reason: store_failed`, the error type, the `surface`) is appended, and
the bypass is refused: `nova seal bypass` exits 1, the route answers `503` and records
the attempt. If even the abort entry cannot be written, the refusal says so and an
ERROR is logged; the log then shows a `seal.bypass.create` for a bypass that does not
exist, which verification never honours (below).

**A bypass counts only while the audit log vouches for it (works today; review r8).**
`nova seal verify`, `nova seal approve`, the `nova serve` / `nova server` seal routes
and `nova export-model-independence` honour a stored bypass only when the audit log
(`NOVAFABRIC_AUDIT_LOG_PATH`, as resolved where verification runs) holds a
`seal.bypass.create` entry for the same capsule, `bypass_uuid` and envelope SHA-256
whose own `entry_hash` recomputes, and no `seal.bypass.abort` for that `bypass_uuid`.
This is on top of ADR-0315's checks (trusted signer, policy approver, this capsule,
unexpired). A bypass file planted in the store without `nova seal bypass` — so with
no audit entry and no notification — does not count, and neither does one whose
envelope was swapped after it was audited. An unreadable audit log vouches for
nothing.

| Variable | Effect |
|---|---|
| `NOVAFABRIC_PROMOTE_BYPASS_AUDIT_OPTIONAL=1` | skip the audit-entry check, for verifying a bundle store copied without the audit log it was written to. Not the default; the message then says `audit entry not checked` |

Upgrading: a bypass created by a release before 2026-10-23 has no `seal.bypass.create`
entry, so it stops counting after the upgrade. A bypass lasts at most 168 hours;
create a new one, or set the variable above until the old one expires.

**Concurrent writers keep the audit chain intact (works today; review r8).** Every
append to the hash-chained audit log reads the last `entry_hash`, writes and fsyncs
under an exclusive `fcntl.flock` on the log file plus an in-process lock, so threads
(the dashboard runs bypasses in worker threads) and processes chain onto the true
predecessor. Before, 8 threads creating 40 bypasses produced 12 `prev_hash`
mismatches. `flock` is advisory and not honoured by every network filesystem (NFS
without lock support, Lustre mounted without `-o flock`): keep the audit log on a
local filesystem, or one that implements `flock`.

**The signature covers the configuration, not the delivery.** The bypass is signed
*before* anything is sent, so the signed predicate cannot honestly say "sent". It
records what was configured when it was signed:

| Field | Value |
|---|---|
| `notification_sent_to` | the configured channels (`file`, `webhook:scheme://host:port`), then each `--notify` address as `mailto:<addr>` |
| `notification_status` | `pending` when at least one channel is configured, `not_configured` otherwise. Never `sent` |

The outcome of the delivery that follows is a `seal.bypass.notify` audit entry with
`outcome` `delivered`, `failed` (with `failed_channels`) or `not_configured`. A
failing channel never blocks the bypass, because the bypass is already stored and
audited by then; the failure is logged as a warning (`bypass notification failed`).
A webhook is recorded as `scheme://host:port` only, because its path or query may
carry a token. Bypasses signed before 2026-10-23 may carry the legacy values `sent`
(from `--notify`, although no email was ever sent) or `not_configured` (always, from
the dashboard). For those, trust the audit log over the predicate.

Not done: email delivery. `nova seal bypass --notify <email>` records the addresses
in the signed predicate and in the event's `recipients`, but NovaFabric sends no
email. A `--notify` address does not make the status `pending`; use the webhook to
reach a mail or chat relay.

Before 2026-10-22 neither variable had any effect: nothing read them, and no
bypass notified anyone.

---

## 5. Discovery order for `novaseal.yaml`

`load_signing_profile()` searches in this order:

1. `NOVAFABRIC_SEAL_CONFIG` env var — if set and the file exists, use it.
   If set but the file is missing, raise `SealConfigError` (fail fast; do not
   silently fall through to the default).
2. `$NOVAFABRIC_HOME/novaseal.yaml` (default `~/.novafabric/novaseal.yaml`) — if it exists, use it.
3. No config found — NovaSeal is disabled; `load_signing_profile()` returns `None`.

This means NovaSeal is **completely opt-in** at the individual user level.
A system administrator can pre-create `$NOVAFABRIC_HOME/novaseal.yaml` (or set
`NOVAFABRIC_SEAL_CONFIG`) for the user (e.g., via SLURM prologue or dotfiles management) to
enable NovaSeal transparently.

---

## 6. Troubleshooting

### The dashboard doctor (`/api/doctor`) shows `novaseal_db FAIL`

The diagnostic check resolves the log with the §3.1 rule and tests whether the
file exists. A Postgres log is reported with its password redacted and is not
connected to by this check. (`nova doctor` on the command line has no NovaSeal
check.) Common causes:

| Symptom | Cause | Fix |
|---|---|---|
| Path shown is `~/.novafabric/novaseal-merkle.db` but the file isn't there | No capsule has been sealed yet — the DB is created by the first sealed `nova capture` (or any NovaSeal seal operation) | Run a seal operation, or pre-create the file: `touch ~/.novafabric/novaseal-merkle.db` |
| `novaseal_db FAIL` after setting `NOVAFABRIC_SEAL_DB_PATH` | The env var path doesn't exist yet | Create the parent directory and touch the file, or run a seal operation |

### `SealConfigError: NOVAFABRIC_SEAL_CONFIG points to missing file`

The env var is set but the file at that path doesn't exist. Either create the
file or unset the env var to fall back to `$NOVAFABRIC_HOME/novaseal.yaml`.

### NovaSeal silently disabled — no seal directory in capsule

`load_signing_profile()` returned `None` (no config found). Run `nova seal init`,
create `$NOVAFABRIC_HOME/novaseal.yaml` yourself, or set `NOVAFABRIC_SEAL_CONFIG`.

### `⚠ NovaSeal sealing failed … does not match the public key`

The `key_path` key is not the key in `cert_path`, so any seal would fail
verification; capture writes the capsule without `.seal/` instead (ADR-0301). Re-issue
the certificate — for a `nova seal init` identity, `nova seal init --force`.

### `nova init --force` refuses: "signs capsules with … signing_key.pem"

Your `novaseal.yaml` points `key_path` at the `nova init` Ed25519 key. Rotating that
key would orphan the certificate, so `nova init --force` stops. Rotate the sealing key
with `nova seal init --force`, or point `key_path` at a different key first.

### Merkle log differs between two shells or jobs

Every code path uses the §3.1 rule, so within one process sealing and
verification agree. Two processes disagree only when their environment differs:
check for a stale `NOVAFABRIC_SEAL_DB_PATH` or `NOVAFABRIC_SEAL_CONFIG` exported
in one shell or job script but not another.
The promotion policy store follows the same rule. `nova policy sign`, `nova policy
list`, `nova seal propose` / `verify`, `nova export-model-independence` and
`POST /api/policy/sign` default to the §3.1 Merkle log file, which is what the
dashboard SealTab and server mode read; an explicit `--db` still wins. Up to v0.106.0
the CLI defaulted to `~/.local/share/novafabric/merkle.db`, so a policy signed on the
command line was invisible to the dashboard. **Default changed:** a policy signed
before the change stays in that old file — pass `--db ~/.local/share/novafabric/merkle.db`
or re-sign it without `--db`. `nova seal propose` and `nova policy list` name the old
file when they find no policy and it exists. A Postgres Merkle log cannot hold the
policy store (it is SQLite); pass `--db <file>` then.

### TSA timeout or `tsa_url` errors

There is no default TSA (ADR-0292): NovaSeal contacts only the `tsa_url` /
`tsa_urls` you configure. If you configured the public `https://freetsa.org/tsr`,
note it is a free service with rate limits — for production use an
organizational or commercial TSA. To disable RFC 3161 timestamps entirely, omit
the field (or leave it empty) — NovaSeal still signs with ECDSA, without a
timestamp token and without any network call. If a single
TSA being occasionally unreachable is the actual problem, configure a
fallback list instead of (or as well as) switching providers — see
[§1.1 TSA fallback list](#11-tsa-fallback-list-tsa_urls).
