bitexact — the collector protocol#
How a host collector hands recorded events to the BitExact recorder. An agent never speaks this protocol itself: SDKs in any language post events to the collector on their own host, and the collector forwards them in batches over one connection to the recorder.
The protocol has one form, and no frame carries a version. Conformance
vectors that every implementation must agree with are in
protocol-vectors.json.
Frames#
A frame is a 4-byte big-endian unsigned length followed by exactly that many
bytes of UTF-8 JSON. The JSON is one object with a string type.
A reader refuses, as a protocol error that closes the connection:
- a frame longer than the agreed
max_frame; - a body that is not UTF-8 JSON, repeats a key, carries
NaNorInfinity, or nests deeper than evidence may; - a body that is not an object, or has no string
type; - a connection that closes partway through a frame.
A connection closed between frames is a normal end.
Hashes are always computed over the RFC 8785 canonical form of a value, never over the bytes on the wire.
Unknown fields and messages#
- A reader ignores any field or capability it does not know.
- A message whose
typethe recorder does not define is answered with{"type": "unsupported", "of": "<type>"}, and the connection stays open.
Handshake#
The collector speaks first:
{"type": "hello", "protocol": "bitexact",
"capabilities": ["tokens", "causal", "heartbeat"], "max_frame": 4194304}
The recorder answers:
{"type": "welcome", "protocol": "bitexact",
"capabilities": ["tokens", "causal", "heartbeat"], "max_frame": 4194304,
"key_id": "<recorder signing key id>"}
capabilitiesin the welcome are those both sides offered. Withheartbeatagreed, the collector sends heartbeats (see Heartbeats); a recorder that did not agree it is sent none.max_frameis the smaller of the two, and at least 65536.- A refused hello is answered with
{"type": "error", "reason": "..."}, and the connection closes.
Events#
The collector sends {"type": "batch", "events": [...]}. Each event is:
| Field | Required | Meaning |
|---|---|---|
run_id | yes | The run the event belongs to; the same rules as everywhere else in BitExact |
source_id | yes | The sending collector instance and epoch, for example c-7f3a91:3. Letters, digits and ._:-, up to 128 characters |
seq | yes | The event's position in that source's stream: an integer from 1, with no gaps |
kind | yes | http_call, tool_call, nondet, identity, context, human_decision, decision_record, marker, model or policy_check. Seals, forks, redactions, repairs and holds are never accepted from a source |
data | yes | The payload, an object. It may not contain aer_source |
prov | no | A provenance class the kind may carry; the kind's default otherwise |
ctx | no | The tool frame the event was recorded inside, as the entry field of the same name |
actor | no | The agent's stable name, which replay uses to serve each agent its own calls |
id | no | The event's id, assigned by the SDK that recorded it: letters, digits, _ and -, up to 64 |
hlc | no | A hybrid logical clock stamp, [physical_ms, logical]. The collector re-stamps every event so each source's clock only moves forward and never behind the agent's |
caused_by | no | Up to 32 event ids that caused this event, from causal contexts other agents handed over (bxc.<id>.<physical_ms>.<logical>) |
sdk | no | The capture path the event came through: proxy, sdk-python, sdk-ts, sdk-go, sdk-java, otel, langchain, openai-agents, claude-agent-sdk, adk, auditant, llamaindex, vapi, mcp-proxy or governance (every SDK's governance-decision API: a policy engine's decision or a person's, its gap markers included). Optional: every BitExact writer sends it on every event, its loss markers included, and an event from a sender that names none is recorded without a capture path. Any other value never costs the event: the collector accepts it and the recorder records it without a capture path, declaring the label it refused (below). The label is asserted by the sender, never authenticated |
custody | no | The agent's custody hints: an object mapping a field path of data to scrub, hash-only or scrubbed-upstream, which may name the tool that cleaned the value (scrubbed-upstream:presidio). The collector applies them with its capture policy and does not forward them; a recorder that receives them applies them with its own. Any other shape is refused, by the collector at intake and by the recorder as rejected |
capture | no | What the collector's capture policy did to the event: {policy, applied_by, fields, commitments}. Only the collector writes it: an agent's event that carries it is refused at intake. applied_by must be the event's source_id, and commitments names exactly the hash-only and withheld fields, which data no longer holds. An event without it is scrubbed by the recorder, declared applied_by: recorder. No hint or declaration is ever read from inside data: every key of data is content, whatever its name |
token | yes | The run token that authorizes the write (see below) |
Custody hints#
An agent may say how a field must be kept: stricter than the capture policy
(scrub, hash-only, never looser), or cleaned before BitExact saw it
(scrubbed-upstream, recorded as the agent's claim;
scrubbed-upstream:TOOL names the tool, letters, digits and ._-, up to 64
characters, starting with a letter or digit). Any other hint refuses the
event. A tool wrapped with custody sends its event as:
{"run_id": "run-1", "source_id": "c-abc:1", "seq": 1, "kind": "tool_call",
"custody": {"args": "scrubbed-upstream:presidio", "result": "hash-only"},
"data": {"tool": "kyc_lookup", "args": ["[PERSON], born [DATE]"],
"status": "ok", "result": "match"}}
The collector spends the hints when it applies its host's policy, and the
entry declares args as {"mode": "scrubbed-upstream", "by": "presidio"}
and result as {"mode": "hash-only"}: committed, its value not kept.
Credentials#
A query parameter, object key or name=value text named in the credential
table (credential_keys in protocol-vectors.json,
compared without case) carries a credential. A wrapped client records such a
query parameter with its value replaced by [redacted], URL-encoded, and
every other parameter as sent:
https://api.example.com/v1/files?token=t0k&sig=abc&page=2 is recorded as
https://api.example.com/v1/files?token=%5Bredacted%5D&sig=%5Bredacted%5D&page=2.
A parameter's name is matched once URL-decoded, ignoring ASCII case only:
api%5Fkey is a credential, a name that does not decode is compared as sent,
and a look-alike letter (%E2%84%AAey, a Kelvin sign) names another
parameter. Every other byte of the URL is kept exactly as sent: each other
parameter's encoding, an empty parameter and the fragment. A user name or
password in the URL is recorded as [redacted] too. The recording proxy is
sent the query alone and records it by the same rule. The capture policy's credentials detector replaces the value with
[CREDENTIAL]. The vectors' recorded_urls and scrubbed rows are the
encoded output every implementation must give.
What the recorder writes#
The recorder records each accepted event as an ordinary chain entry. The
entry format, its hashing, its signing and the open verifier are all
unchanged. The source coordinates go into the entry's data under
aer_source, which only the recorder writes:
"aer_source": {"source_id": "c-7f3a91:3", "seq": 812, "actor": "planner",
"id": "7f3a91c2b0e4d5a6", "hlc": [1730000000123, 4],
"caused_by": ["1b2c4d5e6f708192"],
"peer": "spiffe://acme/agents/h12", "token": "<jti>"}
An event that names its sdk label is written with the entry field via
set to relay: and the label (relay:sdk-ts), inside the entry hash; an
event that names none is written without via. An event whose label is
outside the set is written without via too, and its source block declares
the label it refused by the length of the label's JSON encoding (keys
sorted, compact, ASCII) and a digest of that encoding keyed with the capture
key, "sdk_refused": {"bytes": ..., "keyed": ..., "nonce": ..., "key_id": ...}, which a holder of that key tests a candidate label against with
capture_policy.open_withheld. With no capture key the digest is dropped
and said so, "sdk_refused": {"bytes": ..., "digest": "dropped: no capture key"}, and a capture policy that cannot be read drops it as "dropped: capture key unavailable", declared capture_key_unavailable. The label is
the sender's free text, so its value is never written, and an unkeyed
digest of a short label would let anyone test each guess at it. The source
block's actor is the sender's free text too, and passes through the
capture policy's detectors, at the collector and again at the recorder. The
label is what the sender asserts: any process holding the host secret could
send any label.
The peer beside it is what the recorder's mTLS authenticated.
The recorder checks every causal claim and declares what does not hold,
as a causal_inconsistency gap beside the event:
- a source's clock that does not move strictly forward;
- a clock more than
BITEXACT_HLC_MAX_SKEW_MSahead of the recorder's; - an event id used twice in a run;
- and, at the seal, any
caused_bynaming an event the run never received.
Identity events#
An identity event's data is checked field by field by the one identity
rule every write door applies (the in-process SDK and the recorder alike;
the cases are identity_fields and identity in
protocol-vectors.json):
scopeis a list of strings; every other known field is a string, andon_behalf_ofandauthority_refare not empty;on_behalf_of_typeis one ofhuman,organization,serviceoragent, andinteraction_modeone ofinteractiveorautonomous;on_behalf_of_typeandauthority_refcome withon_behalf_ofin the same event;workflow_digestis never accepted: the recorder computes it from the composition fields whenworkflowis present;- a field set to
nullis left out, and a key the protocol does not name is kept as sent; - an event that states no field at all (
{}, or every fieldnull) is refused whole, with a gap whosefieldsis empty: no identity entry lands that a reader could take for a bound one.
A field the rule refuses is never written in a wrong shape. The recorder writes the rest of the event, and beside it a gap marker:
"gap": {"cause": "invalid_argument", "where": "recorder",
"fields": {"interaction_mode": "interaction_mode must be one of interactive, autonomous"},
"withheld": {"length": 33, "keyed": "<hex>", "nonce": "<hex>", "key_id": "<hex>"},
"seqs": [812, 812], "source_id": "c-7f3a91:3", "peer": "...", "policy": "open"}
fields names each refused field and why; withheld is the length of the
canonical JSON of the refused fields as sent and its SHA-256 keyed with the
capture key, so a capture-key holder proves what was refused
(verify --open-withheld) without it being stored. With no capture key, or
no capture policy, the digest is dropped and said so
("digest": "dropped: no capture key"): a refused principal or party is
short enough to guess, and an unkeyed digest would let anyone holding the
bundle test each guess. When the rule keeps nothing, the marker takes the
event's place and carries its aer_source; the result is gap_recorded
with cause invalid_argument. The entry's aer_source names the event as
the source sent it, so a resend of the same seq is still a duplicate and
never mistaken for equivocation: as sent, an unkeyed digest of the whole
event, when the record holds all it hashed; as sent_keyed, keyed with the
capture key, when the identity rule refused a field or the capture policy
changed what is stored; and not at all when that happens with no capture
key. A redaction of any field of the entry redacts sent with it (the
redaction claim names it): left in place, it would let anyone holding the
bundle test a guess at the withheld value. A resend of an entry whose
sent was redacted or dropped is taken as a duplicate.
bitexact show --causal prints a run in causal order: each source's own
sequence and the caused_by links, ties broken by clock and then by chain
position. bitexact.run(..., mode="replay", actor="planner") replays one
agent of a multi-agent run against its own entries only.
Ending a run#
{"type": "source.end", "run_id", "source_id", "last_seq", "run_end", "token"} says a source has sent everything it will send to a run, up to
last_seq. With run_end, it also asks for the run to end. The recorder
seals the run once every source that wrote to it has ended and everything
each said it sent has landed. If that has not happened once
BITEXACT_SEAL_GRACE_S has passed since the request, it seals anyway, with a
source_gap declared for each source still open. A source that ends speaks
for the earlier epochs of the same collector (<collector>:<epoch>).
peer is the identity of the authenticated mTLS peer the event arrived
from. It is taken from the connection and never from the payload. token is
the id (jti) of the run token that authorized the write.
Results#
The recorder answers each batch with {"type": "acks", "id": ..., "results": [...]}, one result per event, in order:
status | Meaning | The collector |
|---|---|---|
appended | Durably on the chain at step | may erase it |
duplicate | Already on the chain, unchanged | may erase it |
gap_recorded | Not written; the run declares why (cause) | may erase it |
rejected | Never writable as sent (reason) | may erase it, and counts it |
out_of_order | Retry: send expected first | keeps it |
unavailable | Retry: the recorder could not store it now | keeps it |
A source that lost events before they reached the recorder declares the hole
with {"type": "gap", "run_id", "source_id", "first", "last", "cause", "token"}, where cause is spool_overflow or source_gap. The run records
it as a capture gap, and the source resumes at last + 1.
Heartbeats#
With the heartbeat capability agreed, the collector sends a heartbeat as
soon as it connects and then every BITEXACT_HEARTBEAT_S seconds (default
60):
{"type": "heartbeat", "source_id": "c-7f3a91:3",
"pending_events": 0, "spool_bytes": 0, "spool_retrying": 0,
"spool_unreadable": 0, "spool_max_bytes": 1073741824, "unrecorded": 0,
"dropped_ranges": {"run-42": [[13, 15]]}, "heartbeat_s": 60,
"seen": [{"run_id": "run-42", "sdk": "sdk-ts",
"last_accepted_at": "2026-10-30T13:59:10+00:00"}]}
| Field | Required | Meaning |
|---|---|---|
source_id | yes | The collector instance and epoch, as on its events |
pending_events, spool_bytes, spool_retrying, spool_unreadable, spool_max_bytes, unrecorded | yes | The collector's own counts, each an integer of at least 0 |
dropped_ranges | yes | Run id to the [first, last] seqs the collector lost before they reached the recorder |
seen | yes | Each (run_id, sdk label) the collector accepted events for since the last heartbeat the recorder kept, with last_accepted_at, the newest local accept time as an instant with its offset. sdk is null for events that named none or a label outside the set |
heartbeat_s | no | The collector's interval, a positive number of seconds |
hwm | no | The runs this source has settled since the recorder last kept each: {run_id, seq, token}, the highest seq it assigned there with none still spooled, and the run's token. Seqs above what the run holds are declared a source_gap in the source's own stream, under that token's checks |
trust_anchor | no | What the CA file the collector checks the recorder against (--tls-ca) says of its lapse, read at each beat: {"path", "not_after", "subject", "fingerprint", "error": null} - an instant with its offset and the lowercase SHA-256 hex of the certificate - or, for a file that does not read, {"path", "error"} with the other three null; each text at most 4096 characters. The recorder publishes it as the collector's expiry fact (service collector, host the peer its certificate names) for the certificate alerts (Ops health); a malformed one refuses the heartbeat |
A heartbeat whose seen would not fit one frame is split across frames.
The recorder answers each with {"type": "ok"} once it has kept it - with results, one per hwm entry, shaped as a batch result plus run_id, when it carried any - or
{"type": "refused", "reason": "..."}; whatever was not kept is sent again
with the next heartbeat. The recorder keeps it as operational state under
the peer its certificate names and the source id, resolving each run to its
workflow, environment and agent: never evidence; only a settled hwm entry writes to a chain, as the source's declared hole, and never opens or extends a sealed run. It is never in recording's way. It tells a quiet agent from a dark host; it
never stands in for evidence that a run was recorded.
Authorization#
Nothing is written without it.
- Transport. The recorder serves TLS 1.3 and requires a client certificate from the configured CA. It refuses to start otherwise. Plaintext exists only as an explicit development switch that binds loopback.
- Identity. The peer is the SPIFFE ID (URI SAN) in the client certificate, else its common name.
- Run tokens. Every event carries one. A token is canonical JSON claims
(
format:aer-run-token,jti,run_id,peer,kinds,exp, and for a delegated tokendelegated_from) signed with the recorder's own key, asbase64url(claims).base64url(signature). It authorizes exactly one peer to write the listed kinds into one run untilexp, by recorder time. A collector asks for one with{"type": "token.request", "run_id", "kinds"}and is answered{"type": "token", "token"}with the kinds the policy grants, or{"type": "refused", "reason"}. A token is refused if its signature does not verify, it has expired, it names another run or another peer, it does not grant the kind, or itsjti, or that of any token it was delegated from, has been revoked. A token can never grant a kind a source may not send. A token whose claims do not name that format is refused, so no other document the recorder key signs reads as one. - Delegation.
{"type": "token.attenuate", "run_id", "token", "peer", "kinds", "ttl_s"}, from the parent token's holder, is answered{"type": "token", "token"}orrefused. The child grants a subset of the parent's kinds, expiresttl_sfrom now and never after the parent, is bound to the sub-agent host's peer, and names indelegated_fromthe{jti, peer}of every token above it, root first, at most 8 deep: it can only narrow. Every entry a delegated token authorizes carries the chain inaer_source.delegated_from. - Refused writes. Every write, hole, source end or attenuation a token
does not authorize is answered
rejected(orrefused). In a run that exists it is recorded on the run's custody chain aswrite-refused, naming the reason, sources, seqs, kinds and the token by digest, withidentity: observedandsubmitted_bythe authenticated peer; a refused write never opens a run. When no fresh token can cure the refusal (the kind is not granted, or the token is delegated and has expired or been revoked), the answer isgap_recordedwith causetoken_rejected: a gap takes the event's place, carrying itsaer_source, so the stream continues and a resend is a duplicate. An expired or revoked root token is answeredrejected, and the collector asks for a new one. - One write per seq. A seq resent unchanged is a
duplicate. Resent with different content, it is refused asequivocation: the original stands, and the run declares asource_conflictgap carrying the hash of the rejected content. - A declared loss stands. Content for a seq its source declared lost is
refused as
declared lost, and the run declares alate_after_gapgap carrying its hash. An honest collector can send it, when a spool segment it could not read becomes readable, so it is not called equivocation. - Liveness. A connection that does not say hello within 10 s, or is silent for 300 s, is closed.
What this cannot prove: a compromised agent host can still lie about what its
own agent did. The content, actor, hlc and caused_by are what that host
asserts, as with the in-process SDK. The chain proves which authenticated
peer sent each event, under which token, and that nothing changed after it
arrived.