Deploying BitExact#
BitExact runs entirely inside your VPC. Nothing in it can phone home; license validation is offline. This document covers the listener, identity, storage, and lifecycle decisions an operator owns.
Prerequisite: a license bound to this deployment's recorder key#
BitExact is licensed use only. There is no evaluation mode. Between
expires and the end of grace_days (14 when the license names none) the
deployment keeps recording and logs a license_grace warning at most once an
hour; renew then. Past expires plus grace_days the deployment is
read-only: the recorder
records nothing - checked on each request, bitexact serve forwards every
call to the provider unrecorded, marked x-bitexact-recording: off, and an
SDK run executes its body unrecorded (with BITEXACT_FAILURE_MODE=strict the
proxy refuses with a 402 instead) - replay, fork, regression, anchoring,
purge and every other producing command refuse, and the viewer refuses the
same acts (/api/whoami lists them as license_refuses before anyone
clicks); listing, showing, verifying, exporting and packing the evidence
already recorded keep working, and so do the statutory acts - placing and
releasing a legal hold, and redaction - which a customer owes to a court or a
data subject whatever the state of its renewal. A proxy restarted after a lapse must not cut agents
off from their provider.
With no license, or one that fails its check, the viewer serves nothing but
its sign-in page, the stylesheet and script its pages are drawn with, and
its liveness probe. A browser gets a page saying what is
wrong - from the license check itself - and where this deployment's license
file goes; an API client gets a 402 with the reason in detail. /healthz
answers 200 in every license state, with the state in its body ({"status": "ok", "license": "missing", "standing": "red"}), so an orchestrator never restarts a viewer over
a license.
Without a valid license only bitexact capture keygen, bitexact keygen, bitexact license, bitexact pki, bitexact recorder-key, bitexact version and bitexact witness answer. bitexact collector, bitexact mcp-proxy, bitexact recorder and bitexact serve start in any license state and check the license on each call: they pass the agents' traffic through, or declare a gap, rather than record.
Generate the recorder key on the host that will sign, keep the private half there, and send the public half for the license to name:
bitexact keygen -o /secure/recorder.key
bitexact capture keygen -o /secure/capture.key
export BITEXACT_SIGNING_KEY=/secure/recorder.key
export BITEXACT_CAPTURE_KEY=/secure/capture.key
export BITEXACT_LICENSE=/secure/bitexact.license
bitexact license
keygen writes two files: the Ed25519 key and its post-quantum (ML-DSA)
half, recorder.pq.key, beside it, where every process that signs reads it
(BITEXACT_SIGNING_PQ_KEY names it anywhere else). A signer given the key
without its half signs Ed25519 alone, and bitexact health says
signing_classical_only. keygen prints both public halves - send both
for the license - and the trust-file entry that pins them; give that entry
to every party that verifies your evidence, so a verifier pinned by it
refuses evidence the post-quantum half did not sign. bitexact recorder-key
and bitexact recorder-key --pq print the two halves again.
The capture key keys hash-only fields and the digests of content gaps
withheld (capture-controls.md). Every process that records or applies the
capture policy - the proxy, the viewer, the recorder and each collector -
reads it from BITEXACT_CAPTURE_KEY, and all of them hold the same key.
Both keys are secrets and live with the license, owner-only, never in the data directory or its volume: whoever holds a backup of the evidence must not hold the keys that sign it or open its hash-only fields. Each shipped deployment delivers them so:
| Deployment | Recorder key and its post-quantum half | Capture key |
|---|---|---|
Pilot (deploy/pilot) | secrets/recorder.key and secrets/recorder.pq.key, made by docker compose run --rm keys and installed by setup into the signing volume, which only the recorder mounts | secrets/capture.key, made by the same step and installed by setup into the read-only keys volume |
Compose (deploy/compose) | secrets/recorder-signing.key and secrets/recorder-signing.pq.key, installed by setup into the signing volume, which only the recorder mounts; setup refuses to start the stack without either | secrets/capture.key, installed by setup into the read-only keys volume, for the recorder, the viewer and the collector |
Kubernetes (deploy/kubernetes) | the bitexact-recorder-secrets Secret, both files projected into the recorder alone (the viewer is given only the license from it) | the bitexact-capture Secret, projected into the recorder and the viewer and mounted in each collector sidecar |
systemd (deploy/systemd) | /etc/bitexact/recorder-signing.key and /etc/bitexact/recorder-signing.pq.key, owned by the bitexact user | /etc/bitexact/capture.key on the recorder, and a copy on each collector host |
The recording proxy (bitexact serve) holds a third secret, the host key:
32 random bytes it seals each streamed response's write-ahead journal under
(capture-controls.md, What else the policy reaches). It reads the file
BITEXACT_HOST_KEY names or, unset, <data directory>.host.key beside the
data directory, and makes it owner-only on first use when it is missing. It
is never inside the data directory - a path there is refused - so a backup
of the store never carries the key that opens a journal left behind, and
only a process holding it opens one (bitexact capture journal FILE). In a
container the data directory is a volume such as /data, and its default
place (/data.host.key) is on a root the image's user cannot write: name a
writable file, or one made ahead, outside the data volume. The pilot's
keys step makes secrets/journal-host.key and setup installs it with
the other secrets, as BITEXACT_HOST_KEY for the proxy and the cli
service. The Compose, Kubernetes and systemd deployments run no proxy and
need none; give a proxy added to one the key as the capture key is given.
One deployment per key. A license records only when the deployment signs with a recorder key it names, and turning signing off does not slip the binding; a license that names none records nothing. A second stack needs its own key and its own license.
Copying both the license and the key to a second host cannot be refused by
an offline product, but it is not invisible: every sealed run commits a
per-deployment fingerprint, so one license_id emitting bundles under two
fingerprints is evident in the evidence itself.
Trusted platform#
Every 90 days the license is renewed by file
(Licensing), and the renewal
request carries a TPM 2.0 attestation of the recorder host. It is taken only
when you run bitexact license request, never while recording, so a TPM
problem can delay a renewal but never stops a call or a recording. The host
needs:
- A TPM 2.0 the product can reach. On Linux,
/dev/tpmrm0, readable by the user that runs the request (thetssgroup, or root); in a container, pass it through (--device /dev/tpmrm0; on Kubernetes, a TPM device plugin). On Windows, the TPM Base Services, which every Windows host has. - The endorsement hierarchy with no password, which is the default. Nothing is stored in the TPM: its endorsement and attestation keys are derived from the standard TCG templates each time.
- The TPM's endorsement-key certificate, in the TPM's NV storage, where discrete TPMs keep it. Firmware TPMs (Intel PTT, AMD fTPM) often do not: obtain it from the manufacturer's certificate service and pass the file to the request.
- On Linux, integrity measurement (IMA): kernel 5.8 or later, booted with
ima_template=ima-ng ima_hash=sha256, securityfs mounted at/sys/kernel/security, and a policy that measures the product's files when they are loaded (measure func=FILE_MMAP mask=MAY_EXECfor the compiled modules, andmeasure func=FILE_CHECKfor anything read as a file). The request reads the measurement log, so run it as root. A host whose policy measured none of the product's files is refused renewal: the attestation is how BitExact learns the files it renews for are the ones it shipped. - On Windows, the TPM measures boot, not files. Whether renewals from a Windows recorder host are accepted is stated in your Order Form; a Linux recorder, with Windows agents sending to it, attests files.
The listener#
bitexact serve binds loopback by default. For anything beyond one
host, terminate TLS on the listener itself:
bitexact serve --host 0.0.0.0 --port 8787 \
--tls-cert server.crt --tls-key server.key
Add mutual TLS — only workloads holding a client certificate signed by your CA can record:
bitexact serve ... --tls-client-ca clients.pem
The viewer takes the same pair, so bearer tokens and evidence never cross the network in the clear:
bitexact view --host 0.0.0.0 --port 8788 \
--tls-cert viewer.crt --tls-key viewer.key
A network bind needs an auth mode as well; the viewer refuses one without (see The viewer below).
Behind a reverse proxy (nginx, Envoy, an SSO-terminating gateway),
keep the recorder on loopback or a private interface and let the proxy
own the public listener. The recorder does not implement SSO
deliberately: identity termination belongs to the infrastructure your
security team already audits. Forward X-BitExact-Run-Id unchanged.
Give the proxy the client-facing deadlines too. The recorder applies no
request-read timeout of its own: a client that sends its request a byte at a
time holds its connection open for as long as it likes, and enough such
clients exhaust connections before they exhaust anything else. Set the
request-read and header-read deadlines and a per-client connection cap on
whatever fronts it (client_header_timeout, client_body_timeout and
limit_conn in nginx; the equivalents elsewhere). On a loopback-only
recorder with no public listener there is nothing here to set.
Reserved local routes — never proxied upstream, never recorded:
| Route | Purpose |
|---|---|
/healthz | liveness: 200 whenever the proxy answers; store writability and disk headroom in the body (recording: ok or degraded, with reason) |
/version | running version |
/metricsz | request/error/recording-failure counters, latency, store size, the store-write breaker's state and trips |
/health/recording | whether calls are being recorded now (store, breaker, license, last write, disk headroom); 503 with the reason while degraded (Ops health) |
Point orchestrator liveness checks at /healthz: it stays 200 while calls
pass through, whatever the state of recording. Alert on /health/recording,
and set BITEXACT_DISK_MIN_BYTES so it reports degraded before the disk
actually fills.
Ending a proxy run over HTTP#
POST /bitexact/runs/<run-id>/seal appends a run's seal - evidence of where
it ended - so it is closed until you open it. Make a secret, owner-only, and
name it on the recorder:
(umask 077; head -c 32 /dev/urandom | base64 > /etc/bitexact/seal.secret)
export BITEXACT_SEAL_SECRET_FILE=/etc/bitexact/seal.secret
The clients that end runs send it as Authorization: Bearer <secret>:
curl -X POST -H "Authorization: Bearer $(cat seal.secret)" \
http://localhost:8787/bitexact/runs/loan-42-decision/seal
| Answer | When |
|---|---|
200 | sealed (or already_sealed) |
202 | through a host collector: the end is queued behind the run's events |
401 | no secret, or the wrong one |
403 | BITEXACT_SEAL_SECRET_FILE is unset, or the run was not recorded by the proxy |
402 | the license is missing, invalid or expired (license_lapsed), or does not name this recorder key (recorder_key_unlicensed) |
503 | the secret file cannot be used, the recorder key is not configured (signing_key_missing), or the signer failed (recorder_unavailable) |
409 | a live SDK run holds the run |
404 | no run by that id |
400 | the run id is not one the store can use |
405 | any method but POST |
The route seals only runs the proxy recorded: one it opened, or - through a
host collector - one this process sent events for since it started. An SDK
run seals itself; the recorder seals what its sources end; anything else is
bitexact seal RUN on the recorder's host. The secret lets a client end any
run the proxy recorded, so give it to the clients that end runs, not to
everything that can reach the proxy. The file is read on every request, so
replacing it rotates the secret at once.
The viewer#
bitexact view never edits or deletes a recorded entry. It does act, and
every act is itself evidence: placing or releasing a legal hold and requesting
or approving a redaction append audit entries to the run's chain (an approved
redaction destroys the named values in place and leaves their commitments);
a fork, a fork matrix, a regress and a suite run record new runs or reports;
a case pins runs on its own ledger. Every such route requires the
X-BitExact-Action header and a role that may perform it, and the full
route list is docs/api-reference.md. With no authentication configured every
caller is admin, so bitexact view refuses any non-loopback --host until
BITEXACT_VIEWER_TOKENS or BITEXACT_TRUST_PROXY_AUTH is set. Set
BITEXACT_VIEWER_TOKENS to require a token. An API client sends
it as Authorization: Bearer <token>. A person opening the inspector in a
browser is sent to its sign-in page, which exchanges the token for an
HttpOnly, SameSite=Strict session cookie lasting
BITEXACT_VIEWER_SESSION_HOURS (default 8). The cookie is signed with the
token, so removing a token from the configuration ends every session it
opened. Sign out is in the inspector's main menu. Repeated failed sign-ins
from one address are throttled.
Every request emits a structured access-log line, and evidence-touching
actions are additionally recorded on the run's custody chain. For team access,
put it behind the same SSO proxy as everything else, and name that proxy in
BITEXACT_TRUSTED_PROXY_IPS (default: loopback) so forwarded identity headers
are believed from it alone - see identity-access.md. Every request body -
an upload to the in-app verify endpoints included - is capped at
BITEXACT_MAX_UPLOAD_BYTES (default 64 MiB, 413 above it), and the cap
applies before a route authorizes; evidence larger than that is for the
offline verifier. A forwarded identity header sent twice is refused.
The agent entrypoint#
Four things in the viewer execute the customer's agent: forking a decision, sweeping one N times, regressing a corpus against the code running now, and running a saved suite as a gate. All four need the callable that runs that agent, and none of them will take it from a request - that would be arbitrary import over HTTP. It is the operator's, named in an environment variable on the viewer process:
BITEXACT_AGENT_ENTRY=myapp.agent:run_once bitexact view
The entrypoint may take the run as its argument or take none and
reach for bitexact.current(); both conventions work.
Unset, each of those four reports that nothing is configured and does nothing;
they are off by default. Each is gated by its RBAC action (fork, regress)
and the matching license entitlement.
These jobs re-execute the agent sequentially against one rate limit, so one
runs at a time; a second is refused with how far along the first is. A sweep
is capped at 1000 forks from the browser - a larger one is a batch job for
bitexact fork-matrix, which has no cap.
An agent that uses the explicit-capture hooks (record_decision,
record_human_decision, record_context_*, record_marker) needs no changes
to be forked, replayed or regressed: those hooks are inert outside a recording
run, so the same entrypoint serves every mode. The request carries only data - which step to
branch at, and optionally a replacement model response - and the branch is
gated by the fork entitlement and the fork RBAC action like any other
write.
The entrypoint must re-execute the recorded path up to the fork point. If it
does not, the replay diverges and the API returns 409 naming the exact
diverging step; nothing is sealed. If the agent itself raises while
continuing, that is also a 409, naming the agent's own exception - a
counterfactual that never ran is never presented as one. A branch is named
after its source (<run>-fork-<id>) and, if it did not complete, stays
unsealed.
To try it against the seeded sample, point the variable at the sample's own agent:
bitexact seed
BITEXACT_AGENT_ENTRY=bitexact.sample:sample_agent bitexact view
Then, in the inspector: open loan-decline-sample, press ⑂ Fork, and set
Replace a model response to the run's one http_call. The box fills with
what the model actually returned - a DECLINE citing 46% DTI and a 604 score.
Change that text to an approval and press Create fork. The branch records
is_adverse_action: false with no ECOA reason codes and the reviewer's
decision as approve, because the sample's agent derives its decision from
the model's answer and that same code re-ran against the new one. The replaced
step is marked synthetic.
Replace the response rather than forking before it. A fork point earlier
than the model call sends the continuation to the real provider, which without
an upstream credential returns 401. The branch is still recorded honestly -
the 401 is in the chain - but the agent may not check the status, so it can
seal having tested nothing. The dialog reports any such refused call rather
than showing a bare success, and POST /api/forks returns them in
failed_calls.
Regressing and suites from the viewer#
Replay and fork → Replay diff replays a sealed run against the code running now and reports the first divergence. Regression suites on the same screen saves a corpus and a divergence threshold as a named suite, and runs it as the gate it exists to be; the result is written back and shown there.
Comparing two implementations on the same sealed roots needs two callables at once, so a deployment may name several entrypoints:
BITEXACT_AGENT_ENTRIES=baseline=myapp.v1:main,candidate=myapp.v2:main
The default BITEXACT_AGENT_ENTRY appears in that set as default. A request
picks by NAME; a module path in a request body is never imported, so offering
more entrypoints does not widen what a request can cause to run - the set
stays entirely the operator's. A malformed pair is dropped rather than taken
as a name. With fewer than two configured, the comparison panel says so and
shows the variable to set.
Agents on many servers#
To record agents on many servers into a recorder on its own server - so recording costs the agents almost nothing and the evidence never lives on the hosts it describes - run a collector on each agent host and the recorder service on its own. See distributed recording.
Never in the way#
A failure in BitExact's recording never becomes a failure in your pipeline. Where a model call passes through a BitExact endpoint, what happens when that endpoint cannot be reached depends on whether BitExact code runs on the agent's side of the call:
- Wrapped clients (
wrap_openai,wrap_anthropic) send through the run's own recording endpoint, a server inside the agent's process. If that endpoint cannot be reached - its thread has stopped, or the client is used after its run ended - the call fails over. It goes where the proxy would have sent it, by the sameBITEXACT_UPSTREAM*settings, and never anywhere else: a call whose provider has no host configured (Azure, Bedrock) is not sent, and the agent gets the connection error. Each failed-over call is a declared gap:recorder_unavailableon the run, orrun_endednoted beside a run that has ended. On aBITEXACT_COLLECTORhost, which keeps no store, that note goes to the host collector and the recorder writes it beside the run.verifythen exits 3. Only a connection that never opened fails over, so no call is sent twice. Each call tries the endpoint first, and no failover lasts past the call it was made for. The endpoint is on loopback, so its connect is held to 100 ms: a stopped endpoint adds at most that to a call, not the operating system's connect retries (about 2 s on Windows). The upstream call keeps the client's own timeout. Replay, regression and fork runs never fail over. Strict mode raises the connection error instead. - The recording proxy (
bitexact serve), which a client reaches by URL, andbitexact mcp-proxy, over stdio or HTTP. No BitExact code runs on the agent's side of these connections, so nothing of ours can fail the call over.bitexact.mcp.wrap_sessionruns in the agent, but it wraps a session already connected through the proxy, and an MCP session cannot move to another server partway. If the proxy's process is not running, calls pointed at it fail as calls to any stopped service would. Over stdio the MCP server itself is the proxy's child, so it stops with the proxy. Where a proxy outage must not reach the agent, use the SDK or the collector. Where you run a proxy, run it beside the agent it serves, under a restart policy, with a liveness probe on/healthz(see The listener). - The SDK's other capture (
record_tool, the framework adapters) and the collector, which takes events from an in-process queue, put no process of ours in the call path.
When the recorder cannot do its job - an expired license, a full disk, an
unreachable database, a body over BITEXACT_MAX_BODY_BYTES, an argument it
cannot serialize, a run id it refuses - the agent's call goes through
exactly as it would without BitExact, and the evidence says what it missed:
- The response says so. Through the proxy, a call recorded with a gap
carries
x-bitexact-recording: degraded; a call not recorded at all carriesoff. Through the SDK,run.recording_stateisrecording,degradedoroff, andrun.refusalholds the error that turned the run off. - The run says so. The run records a declared capture gap: a signed
markernaming the cause (license_lapsed,signing_key_missing,recorder_key_unlicensed,build_modified(this build's files are not the release's),payload_oversize,storage_unavailable,serialization,invalid_argument,recorder_unavailable,run_id_rejected,run_not_owned,journal_failed,run_ended,run_context_lost,spool_overflow,queue_overflow,intake_rejected,source_gap,source_conflict,late_after_gap,causal_inconsistency,checkpoint_failed,unclassified; docs/bundle-spec.md says what each means), where it happened, and the size of any body it withheld with its SHA-256 - keyed with the capture key, or dropped with no key, under a capture policy. A field the capture policy could not scrub in time (scrub_failed), or keeps hash-only with no capture key (capture_key_unavailable), is declared in the entry itself and counted with the gaps (docs/capture-controls.md). A capture policy that cannot be used opens no run at all (capture_policy_invalid, on the refusal ledger): the agent runs on, unrecorded. A gap that cannot join the chain (the run was already sealed) is noted beside the run; one with no run at all, or one neither the chain nor the note beside the run will take, goes to the log ascapture_gap_unrecorded, every time. So doesrun_not_owned: a call through the proxy that names a run another writer opened - an SDK run, a run the recorder writes for its sources - is forwarded, answeredx-bitexact-recording: off, and written nowhere in that run: no entry, no gap, no note beside it. It is counted and logged with the run id; strict mode refuses it with a 403. - Verification says so.
bitexact verifyexits 3 on a run that declares gaps, listing each cause, until--allow-gapsaccepts them; the viewer marks the run "incomplete capture"; the standalone verifier prints the count beside its OK. An intact chain of an incomplete capture is never presented as a complete record. - Operations sees it. The proxy's
/metricszcountsdegraded_totalandunrecorded_calls_totalby cause, and each change of state is logged at warning:bitexact_degradedwhen a cause starts degrading the process,bitexact_recoveredwhen an entry next commits with no gap, andbitexact_degradedagain if the cause recurs. The proxy's/health/recordinganswers 503 while recording is degraded, and/healthzstays 200 for traffic. - A dead store costs nothing per call. After 5 consecutive store
faults (
BITEXACT_BREAKER_FAILURES) the store circuit breaker opens: for 60 seconds (BITEXACT_BREAKER_OPEN_S) the store is not asked anything - no write, and no read either, such as the lookups the proxy makes to see whose run a call names - so no call waits on a store that will not answer, and each call is a declaredstorage_unavailablegap as any failed write is. A write that fails is a fault; a read is one only when the disk or database failed, not when it found a damaged run. Then one trial goes through: a write that commits closes the breaker, a fault opens it for another full period. It stands in front of every backend. Releasing a run's lock and the/healthzstore check are never held back. Each change of state is logged at warning asstore_breaker;/metricszreportsstore_breaker_state(closed,open,half_open) andstore_breaker_trips_total. A value that is not a positive number leaves the default.
BITEXACT_FAILURE_MODE=strict restores fail-closed recording - the caller
gets an error and no unrecorded response is delivered - for development
and conformance testing. Replay, regression and fork runs always raise:
they serve answers from the record, so a missing record is their failure.
Storage and the disk-full policy#
The data directory must be a local filesystem (advisory locks are not
reliable on NFS/CIFS). When a write or fsync fails — disk full included —
the call is still delivered and the run declares a storage_unavailable
gap (Never in the way); nothing already recorded is
corrupted. Watch /health/recording, set BITEXACT_DISK_MIN_BYTES, and size the
volume for your retention window. BITEXACT_MAX_BODY_BYTES (default 32 MiB)
caps any single recorded body, and how much of one the recorder holds in
memory: a larger one is delivered as it arrives and declared as a
payload_oversize gap carrying its size and its SHA-256, keyed with the
capture key, or dropped with no key.
Retention#
On the embedded file store:
bitexact purge --older-than 90 --archive /evidence-archive
bitexact purge --older-than 365 --delete --yes # destroys evidence
Archived runs keep every file (manifest, sidecars, quarantines, reports) and verify from the archive unchanged. Regress reports accumulate beside manifests and are covered by the same purge.
On the PostgreSQL backend purge refuses, by design: it reclaims the
file store by scanning runs/ on disk, and there is no such directory. Use
bitexact archive instead, which moves a whole month out of the hot tables
and leaves a catalog row behind. See PostgreSQL.
HA stance, stated plainly#
One recorder writes one run. The per-run OS lock makes a second writer fail loudly instead of corrupting a chain — contention is an error you see, not a mode you configure. Run one recorder per agent workload (sidecar or per-host); scale by partitioning runs across recorders, not by clustering writers.
Backup and restore#
PostgreSQL (BITEXACT_DATABASE_URL set): the evidence is in the
database, and file tooling over the data directory copies almost nothing.
Use continuous archiving for point-in-time recovery - see
PostgreSQL.
The embedded store is the whole data directory (BITEXACT_DATA_DIR):
runs, their checkpoints and custody, the transparency log, case and workflow
ledgers, suites and packs. Back up all of it, together.
- Best: an atomic snapshot of the volume it lives on (a ZFS, LVM or cloud
disk snapshot, or a Kubernetes
VolumeSnapshot). It needs no pause. - Otherwise copy it while nothing writes - stop the recorder, the proxy and
any SDK process recording into it - or accept that a run being written as
the copy runs can restore with a torn last line. The store quarantines
such a tail (
<run>.jsonl.torn) andbitexact healthreports it as aseal-failure; the entries before it verify.
The restore drill - run it on a scratch host, on a schedule, not only when you need it:
- Restore into an empty data directory, or a fresh database.
bitexact reindex(the embedded store's index is derived; rebuild it).bitexact health- every run is re-verified; exit 2 on any critical finding, such as a run that fails verification. A quarantined torn tail is aseal-failurewarning: status degraded, exit 0, so read the report.- Export one recent run per workflow (
bitexact export <run> -o r.json) and verify it with the standalone verifier, pinned to your recorder key: that is the check an auditor will make. - If runs are anchored,
bitexact healthhas compared each head against its anchor: a restore that rolled history back below an anchor fails there asanchor-rollback.
Image supply chain#
The Dockerfile pins its base image by digest and builds nothing: it installs
one target's release bundle - the compiled product wheel, the open verifier
wheel, that target's hash-locked requirements.lock and every dependency
wheel it names - with --no-index --require-hashes, then pip check. It
carries OCI labels, runs as a non-root user, and health-checks /healthz.
Images exist for x86_64 and ARM64 under one multi-architecture tag.
The vendor-side release pipeline is scripts/release.sh, run on the release
workstation (releasing.md): after the full checks pass on the
tagged commit it compiles the product for every target in
scripts/targets.py, with the vendor keys baked in before compiling; writes
and signs each build's file manifest; checks every build in a clean runtime
of its own kind; generates a CycloneDX SBOM per target
(scripts/gen_sbom.py); pushes the images, signs their digest and attests
the SBOMs with cosign (customers pin cosign.pub); and attaches the bundles,
SBOMs, image tarballs, SDKs and deployment files to a draft release with a
cosign-signed SHA256SUMS. deploy/compose and deploy/kubernetes name
the image by tag for readability; pin the digest the image line of
SHA256SUMS gives (ghcr.io/bitexact/bitexact@sha256:...) in production.
deploy/pilot takes the image you loaded from the release tarball through
BITEXACT_IMAGE, which the quick start pins to that image's digest.
Every release build carries the vendor keys, compiled in: the build refuses to start without them, and a license is checked against those keys and no other. There is no build in which a customer can sign their own license.
Environment reference#
Every variable the product reads, generated from the code by
tools/gen_reference.py (a test fails when one is missing).
| Variable | Meaning |
|---|---|
BITEXACT_ACTOR | This agent's stable name, sent with every event through the collector; replay serves each actor its own calls. |
BITEXACT_AGENT_ENTRIES | Named entrypoints (name=module:function,...) a suite can compare. |
BITEXACT_AGENT_ENTRY | module:function the viewer forks, sweeps and regresses with. |
BITEXACT_AGENT_PACKAGES | Packages to treat as agent code, not infrastructure, when the agent itself is installed under site-packages - their nondeterminism is then captured rather than skipped. Not an import allowlist and not a security control. |
BITEXACT_BREAKER_FAILURES | Consecutive failed store writes that open the store-write circuit breaker (default 5; Never in the way). |
BITEXACT_BREAKER_OPEN_S | Seconds the open breaker attempts no store write before one trial (default 60). |
BITEXACT_CAPTURE_KEY | A file holding the capture key (64 hex characters, from bitexact capture keygen), provisioned beside the recorder key and kept out of the data directory and its backups. Hash-only fields are committed under it and a gap's digest of content it did not store is keyed with it; without one, hash-only fields can never be opened and those digests are dropped. A policy's own hash_only_key takes its place. |
BITEXACT_CAPTURE_POLICY | The capture policy: a JSON file saying what the evidence keeps of each field - kept, scrubbed by detectors (card numbers, IBANs, credentials by default) or hash-only under a capture key the evidence never holds (see capture-controls.md). Unset, the shipped default scrubs; off keeps every value as captured and every run records that it was off. A policy that cannot be used refuses the process at start. |
BITEXACT_CHECKPOINT_EVERY | Steps between recorder checkpoints when a signing key is configured, after the one every run gets at its first step (default 100). |
BITEXACT_CLI_PREFIX | The command that reaches the CLI on this deployment, when the default for its BITEXACT_DEPLOYMENT is not it (for example docker exec bitexact-viewer bitexact). The viewer's command hints start with it. |
BITEXACT_COLLECTOR | This host's collector intake (http://127.0.0.1:7378). When set, the SDK and bitexact serve record through the collector to a remote recorder instead of into a local store: events are queued and never waited on, and the agent host needs no store and no license. |
BITEXACT_COLLECTOR_HOST_KEY | The file holding bitexact collector's 32-byte host key, which seals its spool segments. Default: <spool>.host.key beside the spool directory, made owner-only on first use; a path inside the spool is refused, so a copy of the spool never carries the key that opens it. A container whose spool is a volume names a writable file on another volume. Not used when BITEXACT_HOST_KEY_KMS_CIPHERTEXT is set. |
BITEXACT_COLLECTOR_SECRET | The host secret for BITEXACT_COLLECTOR. |
BITEXACT_COLLECTOR_SECRET_FILE | A file holding the host secret for BITEXACT_COLLECTOR - the collector writes it as intake.secret in its spool directory. A collector named with no secret turns recording off, never the agent. |
BITEXACT_DATABASE_URL | PostgreSQL DSN; when set the database is the store and the index (see postgres.md). |
BITEXACT_DATA_DIR | Where manifests, sidecars and the run index live (default ./bitexact-data). |
BITEXACT_DB_CONNECT_TIMEOUT | Seconds to wait for the PostgreSQL pool to open before reporting the database unreachable (default 10). |
BITEXACT_DB_LOCK_TIMEOUT_S | Seconds a PostgreSQL statement may wait for a lock (default 5): an append never queues for long behind a maintenance lock such as a month's drop. |
BITEXACT_DB_STATEMENT_TIMEOUT_S | Seconds any one PostgreSQL statement may run before the database cancels it (default 30). A write an agent's call waits on then fails within the bound - a declared gap, never a hung agent; whole-store maintenance (a rebuild, a month's drop) lifts it for its own transaction. |
BITEXACT_DEPLOYMENT | How the viewer is deployed - pilot, compose, kubernetes or container, set by the shipped compose files, manifests and image; unset for a pip install. The viewer reports it in /api/env with the command a person types there to reach the CLI, and its license page says where the license file goes. |
BITEXACT_DISK_MIN_BYTES | Free space below which the proxy's /health/recording answers 503 degraded, naming the shortfall, before the disk fills; /healthz stays 200 for traffic. Recording goes on; a write that then fails is a declared gap. |
BITEXACT_DRAIN_TIMEOUT | Seconds the recorder waits for in-flight streams to finalize on shutdown, and a Python SDK run waits for the entries its writer thread still holds: at the run's exit, and when the agent reads an entry's handle (default 30). Past it the agent goes on: each entry still unwritten is declared a storage_unavailable gap beside the run, naming the journal bitexact repair folds it from, and the run is left unsealed. |
BITEXACT_ENV | The environment label the viewer shows and the sample records (prod, staging, ...). |
BITEXACT_EVENTS_CONFIG | The TOML file naming where events are delivered - Standard Webhooks receivers, Splunk HEC, Datadog Logs - with every secret in a file of its own (see events.md). Unset: nothing is enqueued and nothing is sent. A file that does not load turns delivery off and raises events-config-invalid. |
BITEXACT_FAILURE_MODE | open (default): a BitExact failure never reaches the agent - the call is forwarded untouched, the response carries x-bitexact-recording: degraded (or off with no valid license), and the run gets a declared capture gap. strict: BitExact's own failures are raised and refused as errors, for development and conformance testing. |
BITEXACT_GROUP_PRECEDENCE | The bound groups in the order that decides a principal's role when it is in more than one (comma-separated, most authoritative first). A principal in two or more bound groups the list does not all rank holds no role. A principal in one bound group needs no entry. |
BITEXACT_HASH_ALG | blake2b-256 (default) or sha256 for FIPS-constrained deployments. |
BITEXACT_HEALTH_PORT | Port the container's HEALTHCHECK probes /healthz on (default 8787); set it to the inspector's port when running the image as the viewer. |
BITEXACT_HEARTBEAT_S | Seconds between liveness heartbeats (default 60): the collector's to its recorder, which tells a quiet agent from a dark host, and the row the served proxy and the in-process SDK upsert for each capture path they record through. A collector refuses to start on a value that is not a positive number of seconds; the proxy and the SDK use 60. |
BITEXACT_HLC_MAX_SKEW_MS | How far ahead of bitexact recorder's own clock a source's hybrid logical clock may run (default 300000) before the recorder declares a causal_inconsistency gap beside the event. |
BITEXACT_HOST_KEY | The file holding this host's 32-byte key, which seals the proxy's stream journals (bitexact capture journal opens them only on the host that sealed them). Default: <data dir>.host.key beside the data directory, made owner-only on first use; a path inside the data directory is refused, so a copy of the store never carries the key that opens it. A container whose data directory is a volume names a writable file outside it. |
BITEXACT_HOST_KEY_KMS_CIPHERTEXT | A file holding this host's key encrypted by AWS KMS: the CiphertextBlob of aws kms generate-data-key --key-spec AES_256. Set, the host key that seals the collector's spool segments and the proxy's stream journals is that 32-byte data key, decrypted by KMS once per process into memory; no host-key file is read or made, and BITEXACT_HOST_KEY is not used. A ciphertext KMS will not decrypt, a plaintext that is not 32 bytes, or a process without boto3 (pip install 'bitexact-sdk[aws]') is refused at start. Only a host whose IAM role may decrypt it opens what it sealed. |
BITEXACT_HOT_MONTHS | Months kept queryable before bitexact archive moves a month to the archive. |
BITEXACT_HSM_KEY_LABEL | Label of the ed25519 key object inside the HSM. |
BITEXACT_HSM_PIN | PKCS#11 user PIN. |
BITEXACT_HSM_PKCS11_LIB | PKCS#11 module path for the recorder key. |
BITEXACT_HSM_PQ_LABEL | Label of the ML-DSA key object inside the HSM: the recorder key's post-quantum half, signed with CKM_ML_DSA. |
BITEXACT_HSM_SLOT | PKCS#11 slot holding the recorder key. |
BITEXACT_IMAGE_DIGEST | The container image digest committed into every run's watermark for attribution: the operator's statement of what is running, never measured and never verified. The pilot sets it from the image ID step 1 resolves; another deployment sets it to the digest it pulled (a Kubernetes pod cannot read its own image digest, so name it in the manifest). Unset, the watermark names none. |
BITEXACT_LICENSE | Path to the offline license file. Nothing records or replays without one; the proxy passes calls through unrecorded. |
BITEXACT_LOG_EVERY_S | How often the recorder service signs the transparency log's head and asks the witnesses to cosign it, when the log has grown (default 60). |
BITEXACT_LOG_ORIGIN | The transparency log's name in its checkpoints (default bitexact.log/<recorder key id>). Witnesses and auditors name the log by it. |
BITEXACT_LOG_WITNESSES | Comma-separated witness URLs asked to cosign the transparency log's checkpoints (by bitexact log checkpoint and the recorder service). Unset: checkpoints are signed and unwitnessed. |
BITEXACT_MAX_BODY_BYTES | Largest request or response body, or MCP message, the recording proxy and bitexact mcp-proxy hold to record (default 33554432, 32 MiB; a value that is not a positive whole number uses the default). A body over it is never held: it still reaches the agent whole, as it arrives, and its record is withheld, declared payload_oversize by its hash and length. Strict mode refuses it instead. |
BITEXACT_MAX_UPLOAD_BYTES | Cap on any request body the viewer accepts, including a bundle or report uploaded to its in-app verify (default 64 MiB). |
BITEXACT_MODE | Default run mode for the SDK: record or replay. |
BITEXACT_OTEL_CONTENT | How content ingested from OpenTelemetry spans by bitexact.otel_span_processor() and bitexact.record_otel_spans() is kept: keep (default) leaves it to the capture policy; hash records a tool call's args and result and a model call's request and response hash-only. Any other value is taken as hash, so a mistyped setting never stores more than was asked. It applies to spans alone, where BITEXACT_CAPTURE_POLICY matches a field by kind and path whichever door recorded it; a processor's or batch's content= argument takes its place (see integrations.md). |
BITEXACT_PQ_ALG | The post-quantum parameter set bitexact keygen makes: ML-DSA-65 (default) or ML-DSA-87 (CNSA 2.0). |
BITEXACT_PRICING_FILE | The customer's own pricing table, a JSON file: {"currency": "USD", "per_tokens": 1000000, "models": {"<model as the run records it>": {"input": "2.50", "output": "10.00", "aliases": ["gpt-4o"]}}}, with optional cache_read_input, cache_write_input, cache_write_input_5m, cache_write_input_1h, audio_input, audio_output and tool_use_input rates, every rate a decimal string, and no name given twice. A call is priced by the model its provider reported running, else the one it requested; a count with no rate leaves it unpriced, never dropped. Cost is derived from the token counts each provider reported, shown in the inspector and by bitexact cost, never carried in an evidence pack, and never written to a chain. Unset, BitExact reports tokens only: it never supplies prices. A table that cannot be used is refused by the inspector and bitexact cost, never replaced by a default. |
BITEXACT_RECORDER | The recorder (HOST:PORT) a keyless inspector or CLI hands its acts to. When set and no recorder key is configured, the process writes no chain itself: holds, releases, redactions, custody records, forks, ledger events, seals, anchors, purge records and log checkpoints are acts the recorder checks, writes and signs, and the license binding and log audit read the key's public halves from it. Its acts are refused when the recorder cannot be reached. |
BITEXACT_RECORDER_POLICY | JSON file of run-token issuance rules for bitexact recorder: {"rules": [{"peer": glob, "runs": glob, "kinds": [...], "acts": [...]}]}. A collector gets a token only for the kinds a rule gives its certificate identity in that run; acts are the acts a peer may hand the recorder (docs/distributed.md). A deployment act (ledger, log-checkpoint, log-backfill, ledger-heads, access, events-retry) is granted only by a rule whose runs is exactly *, matched as written, never as a glob. With no policy the recorder issues nothing. Read on every request. |
BITEXACT_RECORDER_TLS_CA | The CA file a process that names BITEXACT_RECORDER checks the recorder's certificate against. |
BITEXACT_RECORDER_TLS_CERT | The client certificate a process that names BITEXACT_RECORDER presents: the identity the recorder's policy grants acts to. |
BITEXACT_RECORDER_TLS_KEY | The private key of BITEXACT_RECORDER_TLS_CERT. |
BITEXACT_REQUIRE_DUAL_CONTROL | Set to 1 to require a second principal to approve every redaction, and to refuse on the command line, where --by is typed and not authenticated, every redaction request or approval and every removal of a critical capture expectation. |
BITEXACT_RUN_GRANT | Set by the SDK, never by hand: bitexact.mcp.proxy_env() puts the active run's grant (JSON: its id, its loopback endpoint and its secret) in the environment it starts bitexact mcp-proxy with, so the proxy records into that run. Absent, the proxy records only into runs it may write itself. |
BITEXACT_SDK_REQUEST_LOG | Set to 0 to stop the SDK's in-process recorder printing a JSON line per model call to stderr (default: every call is logged). Failures are always logged; the standalone recorder is unaffected. |
BITEXACT_SEAL_GRACE_S | How long bitexact recorder waits, after a run is asked to end, for every source that wrote to it to finish (default 30). Then it seals the run anyway, with a declared source_gap for each source still open. |
BITEXACT_SEAL_SECRET_FILE | A file holding the secret (at least 32 characters) a client sends as Authorization: Bearer <secret> to end a run through the proxy's POST /bitexact/runs/<run-id>/seal. Unset, the route refuses every seal; set, it still seals only runs the proxy recorded. Read on every request, so replacing the file rotates the secret. |
BITEXACT_SIGNING_KEY | Path to the recorder's ed25519 private key; with it every run is checkpointed and signed. |
BITEXACT_SIGNING_KMS_CIPHERTEXT | Path to the AWS KMS-encrypted recorder key envelope (see recorder-key-custody.md). |
BITEXACT_SIGNING_KMS_PQ_CIPHERTEXT | Path to the AWS KMS-encrypted post-quantum half of the recorder key. |
BITEXACT_SIGNING_PKCS11 | Set to 1 to sign with a PKCS#11 HSM key instead of a file (see recorder-key-custody.md). |
BITEXACT_SIGNING_PQ_KEY | Path to the recorder key's post-quantum (ML-DSA) half, when it is not <name>.pq.key beside BITEXACT_SIGNING_KEY (see recorder-key-custody.md). |
BITEXACT_SSO_AUDIENCE | The client id the recorder requires in an ID token's aud (with several audiences, also its azp). |
BITEXACT_SSO_ID_TOKEN_FILE | A file holding the operator's ID token, for a command line that names BITEXACT_RECORDER: every act it sends carries it, so a recorder that verifies who acts (BITEXACT_SSO_ISSUER) accepts its acts when --by is the principal the token proves. A file named and unreadable sends no act. |
BITEXACT_SSO_ID_TOKEN_HEADER | The request header an inspector in the SSO proxy mode (BITEXACT_TRUST_PROXY_AUTH) reads the signed-in person's ID token from, to hand the recorder with each act (default Authorization, as oauth2-proxy's --pass-authorization-header sends it; a Bearer scheme is taken off). Read only from a trusted proxy peer; sent twice, it is not read. |
BITEXACT_SSO_ISSUER | Set on the recorder to have it verify who acts: the OpenID Connect issuer, exactly as its ID tokens' iss. Every act that names a person - a hold, a release, a redaction request, approval or rejection, a custody or access record, a dead-letter retry, a ledger event - must then carry that person's ID token, which the recorder verifies itself (signature against the issuer's keys, issuer, audience, expiry and not-before); an act whose by is not the principal the token proves, or that carries none, is refused. The records it writes carry a verified_identity (issuer, subject, claim, principal), never the token; two-person control compares verified people; a role grant needs no stated posture. Needs BITEXACT_SSO_AUDIENCE and one of BITEXACT_SSO_JWKS_FILE / BITEXACT_SSO_JWKS_URL; the recorder refuses to start with any of them set without the rest. Unset, principals are the inspector's statement, recorded as asserted. |
BITEXACT_SSO_JWKS_CACHE_S | Seconds the recorder uses keys fetched from BITEXACT_SSO_JWKS_URL before fetching them again (default 900). |
BITEXACT_SSO_JWKS_FILE | A pinned JWKS file holding the issuer's signing keys, for an offline or air-gapped recorder. Read again when it changes, so rotating keys is replacing the file. Exclusive with BITEXACT_SSO_JWKS_URL. |
BITEXACT_SSO_JWKS_REFRESH_S | The least seconds between two fetches of BITEXACT_SSO_JWKS_URL for a key id the set does not hold (default 30), so a rotation is picked up at once and unknown key ids cannot make the recorder hammer the issuer. |
BITEXACT_SSO_JWKS_URL | The issuer's JWKS endpoint (https://; plain http:// only on loopback). Fetched keys are used for BITEXACT_SSO_JWKS_CACHE_S; a token naming an unknown key id fetches the set again, at most once per BITEXACT_SSO_JWKS_REFRESH_S, and each fetch replaces the whole set, so a withdrawn key is withdrawn. Keys past the cache period that cannot be fetched again are not used. |
BITEXACT_SSO_LEEWAY_S | Seconds of clock skew the recorder allows on an ID token's exp, nbf and iat (default 60, at most 300). |
BITEXACT_SSO_PRINCIPAL_CLAIM | The ID-token claim the recorder takes as the principal: sub (default) or email, which must then be one the issuer verified (email_verified: true). Two acts are one person when they carry one issuer's subject, whatever this claim says. |
BITEXACT_TRUSTED_PROXY_IPS | Peers whose forwarded identity headers are believed (default loopback; * for any). |
BITEXACT_TRUST_PROXY_AUTH | Set to 1 to take identity from the reverse proxy's X-Forwarded-* headers. |
BITEXACT_TRUST_PROXY_GROUPS | Read X-Forwarded-Groups and resolve the role from a bound group, for a proxy that forwards membership rather than a role. Off by default: the header is one many deployments already emit for other consumers, and reading it without an opt-in would silently change what an existing proxy grants. With it on, a request carrying the header twice is refused; with it off the header is not read at all. |
BITEXACT_UPSTREAM | OpenAI-compatible upstream the recorder forwards to (default https://api.openai.com). |
BITEXACT_UPSTREAM_ANTHROPIC | Anthropic upstream the recorder forwards to (default https://api.anthropic.com). |
BITEXACT_UPSTREAM_AZURE | Azure OpenAI resource for openai/deployments/... calls, e.g. https://<resource>.openai.azure.com. No default: unset, such a call is refused (502) rather than sent to another provider with its key. |
BITEXACT_UPSTREAM_BEDROCK | Amazon Bedrock runtime for model/<id>/... calls, e.g. https://bedrock-runtime.us-east-1.amazonaws.com, authenticated with a Bedrock API key. No default: unset, such a call is refused (502). |
BITEXACT_UPSTREAM_GEMINI | Google Gemini API upstream for v1beta/... and models/<m>:<method> calls (default https://generativelanguage.googleapis.com). |
BITEXACT_UPSTREAM_VERTEX | Vertex AI upstream for v1/projects/... calls (default https://aiplatform.googleapis.com; set a regional host such as https://us-central1-aiplatform.googleapis.com). |
BITEXACT_VAPI_HMAC_SECRET_FILE | A file holding the HMAC secret (at least 32 characters) of a Vapi HMAC credential in its default form: x-signature is the hex HMAC-SHA256 of <x-timestamp>.<body>. bitexact.vapi.route() accepts a request that passes this or BITEXACT_VAPI_SECRET_FILE. Read on every request. |
BITEXACT_VAPI_SECRET_FILE | A file holding the shared secret (at least 32 characters) Vapi sends to bitexact.vapi.route() as Authorization: Bearer <secret> or X-Vapi-Secret: <secret>. With neither this nor BITEXACT_VAPI_HMAC_SECRET_FILE set the route answers 503 and records nothing. Read on every request, so replacing the file rotates the secret. |
BITEXACT_VIEWER_COOKIE_SECURE | Set to 1 to mark the session cookie Secure even when the inspector cannot see that the browser used HTTPS. Without it the cookie is Secure when the request arrived over HTTPS directly, or through a trusted proxy that sends X-Forwarded-Proto: https. |
BITEXACT_VIEWER_REPORT_INLINE_RUNS | Largest number of runs the viewer's whole-store reports (overview, health, coverage, retention, trust, a workflow's matrix) cover per request (default 2000). Past it a report is computed in the background, served with the instant it was computed (X-BitExact-As-Of), and answered 202 until the first computation finishes. |
BITEXACT_VIEWER_SESSION_HOURS | How long a browser sign-in with a viewer token lasts (default 8, at most 168). The session cookie is signed with the token, so removing a token from the configuration also ends every session it opened. |
BITEXACT_VIEWER_TOKENS | Comma-separated token:role or token:name:role entries giving each token a role. A named token's principal is token:<name>, so two people with named tokens are two principals and can complete a two-person redaction; an unnamed token's principal is token:<role>. The separator is a colon, a name is letters, digits and ._@+-, and a value this cannot parse, or one set but empty, fails closed, refusing every request. |
LANGCHAIN_PROJECT | Older name of LANGSMITH_PROJECT, honored the same way. |
LANGSMITH_ENDPOINT | LangSmith endpoint recorded as a cross-reference by the LangChain adapter. |
LANGSMITH_PROJECT | LangSmith project recorded as a cross-reference by the LangChain adapter. |