Repository navigation
Format v2
This page specifies tracekit.bundle.v2 and the records, trees and checkpoints inside it. It aims to be precise enough
to write an independent verifier. The reference implementation is tracekit/format/, tracekit/merkle/,
tracekit/bundle_v2.py and tracekit/verify/v2.py; where this page and the code disagree, the code is right and
this page has a bug. v1 bundles (tracekit.bundle.v1) keep their own frozen format and verifier
(signing); nothing here changes how they verify.
Normative test vectors:
| Vectors | What they pin |
|---|---|
tests/vectors/jcs.jsonl |
canonical JSON: input, canonical bytes, SHA-256 |
tests/vectors/sig_v2.jsonl |
records (event, signature message, signed record) and the Ed25519 acceptance rules |
tests/vectors/c2sp_checkpoint.json |
a checkpoint note, its log signature and a witness cosignature |
tests/vectors/slh_dsa_note.json |
a hybrid checkpoint note: Ed25519 and SLH-DSA-SHA2-128s lines, keys, key ids |
tests/golden/v2/ |
bundles (closed, open, approval, run-set) with trust.json and the expected report |
tests/golden/negative/ |
one bundle per rejection, with the expected report in expected.json
|
Every worked example on this page is checked against the code by tests/test_format_doc_vectors.py.
Everything hashed or signed is canonical JSON: JCS, RFC 8785 (keys sorted by UTF-16 code units, no whitespace, ECMAScript number serialisation, minimal string escapes, UTF-8 output). Example:
{"b":1,"a":[1.0,"é",1e21]} → {"a":[1,"é",1e+21],"b":1}
Every JSON text a verifier reads from a bundle or trust config, and the raw arguments a client sends, are parsed by a
strict parser (format.canon.loads_strict) that rejects input JCS could hash differently in different languages:
| Rejected | Error code |
|---|---|
| bytes that are not UTF-8 | invalid_utf8 |
| anything that is not one JSON text | syntax |
| a key twice in one object | duplicate_key |
NaN, Infinity, -Infinity, or a number that overflows to them |
non_finite |
an integer token (no . or exponent) above 2^53 − 1 in magnitude, or longer than 16 digits |
int_range |
a string or key with a lone UTF-16 surrogate ("\ud800") |
lone_surrogate |
arrays and objects nested more than 128 deep ([[1]] is 2 deep) |
depth |
Send larger integers as strings. A tool call whose raw arguments fail strict parsing is denied with rule
TK-ARGS-INVALID and gets no args_commitment (policy).
The signer never publishes a plain hash of something the agent sent. It publishes a commitment, an HMAC that only someone holding the record's salt can test a guess against.
-
Arguments digest:
"sha256:" + hex(SHA-256(JCS({"tool": <tool name>, "args": <arguments>}))), over the arguments as parsed (raw arguments are parsed strictly first). -
Content digest:
"sha256:" + hex(SHA-256(JCS(value)))of a result, model exchange or state write, after the signer's redaction (privacy). -
Salt:
HMAC-SHA256(args_salt.key, label), whereargs_salt.keyis the signer's secret (keys/args_salt.key) andlabelis fixed per record:<type>:<salt_id>when the record'sdatahas asalt_id(current records), else the older labels inpipeline.salt_label(thedecision_idforpolicy.decision,"result:" + decision_idfortool.result). -
Commitment:
"hmac-sha256:" + hex(HMAC-SHA256(salt, UTF-8 bytes of the digest string)).
Worked example (salt = bytes 00 01 02 … 1f):
tool Bash
args {"command":"ls -la"}
JCS {"args":{"command":"ls -la"},"tool":"Bash"}
digest sha256:b7c38435fe712f5634b07e925ba451b8281b9c1480a7df201a2855d9ab409f64
commitment hmac-sha256:b0793fcab9cccb7a6ae4a79f07a4b2d324f04e9fe51df1bd38fdeb0ff4e98d99
tracekit signer reveal --record SEQ prints the salt of one record (auditor guide). One salt opens
one record's commitments and nothing else.
An event is a tracekit.event.v2 object, validated by tracekit/schema/tracekit.event.v2.json (JSON Schema 2020-12;
patterns are full-match ASCII; maxLength counts code points; every string and array is bounded). Unlike plain JSON
Schema, integer means a number token written without a fraction or exponent: "seq": 1.0 is schema-invalid, though
its record hash is that of "seq": 1. It adds to v1:
| Field | Set by | Meaning |
|---|---|---|
log_id |
signer | the record log, 32 hex |
seq, prev_hash
|
signer | position in the log and the previous record's hash (sha256: + 64 zeros at seq 0) |
tenant, run_id
|
signer | the run; signer-level records use tenant tracekit, run tracekit/signer, and a tenant's own signer records (the content vault's) that tenant's run tracekit/signer
|
run_seq, run_prev_hash
|
signer | position in the run and the run's previous record hash (zeros at run_seq 0) |
tenant_attested, principal, principal_attested
|
signer | whether tenant and principal came from the caller's verified identity |
request_id, stream, client_seq
|
client | idempotency key and per-stream counter; a skipped client_seq becomes a signer gap |
tool_call_id, attempt
|
client | the call a record is about |
tier |
signer |
T1/T2/T3 evidence tier, when known; OTLP imports are T3
|
step, capture_layer
|
— | defined by the schema; the signer does not write them yet |
args_commitment, args_source
|
signer | the commitment (§2) and whether arguments were raw, parsed or coerced
|
engine |
signer | the policy engine id@version
|
span_id |
signer | the OTel span an imported record came from |
source is hook, proxy, transcript, signer, sdk, gateway, migrated or import. Gap and tamper records
(capture.gap, trace.tamper) are written only with source: signer; the kinds are listed in the schema's
capture_gap and trace_tamper definitions. A gap marked tenant_level: true (witness_failed, witness_late,
clock_skew, degraded_unanchored, and rollback tamper records) concerns every tenant (§7).
Lifecycle records: run.registered (agent, the caller identity, signer_isolation and fail_modes from the signer's
own measurement and config, and with harness binding the harness{name, exe, pid, start_time, attested} process the
caller descends from), run.closing, the run's reconcile.* records, then run.final{head_run_seq, head_hash, coverage}. A run started by another run of the same tenant (a sub-agent's: register_run parent, whose run token
the caller presents, for a parent that is not final) has parent_run (that run) and session (the root of its chain of
parents) in its run.registered (format 1.2); a run without them is the root of its own session, whose id is its
run_id. A tool call the tool executor or the MCP gateway decided, ran or received
for the run's client is recorded source: gateway, tier T2, its policy.decision, approval.request, tool.result
and input.observed naming that service's signer identity (scheme:subject) in via, and its policy.decision the
client it decided the call for in caller (format 1.2); the signer takes such a call's approval request, consume and
completion from that service, for that client, alone. A run that ends with such a call allowed (or approved and
consumed) and no result, after waiting gateway_wait_s for it, gets a signer capture.gap{kind: held_call_unresolved} for it. Key records: signer.epoch{keys: [{kid, alg, spki, cert?}], bridge?} (cert: the key's issuance entry from a record-key issuer, issuer.md)
and key.retire{kid, last_seq}. The log's last record, if it is ever closed: log.closed{final_seq}.
Provenance (format 1.1, policy): input.observed{source, trust, output: {hash, size, redacted}, redaction, salt_id} records content that entered the run through the
observe RPC (a mail, a ticket, the task), committed to as a tool result is (§2), never in clear; source is the
caller's name for it and trust its claim. policy.decision carries provenance_state (complete, partial,
truncated, unavailable or timeout; written only when it is not complete or provenance is non-empty, so a run
with nothing untrusted in its arguments needs no 1.1 verifier), provenance_reason (why it is not complete, at most
256 characters) and provenance: at most 16 {field, kind, source: {run_seq, tool?, tool_call_id?}}, the top-level
arguments whose values only untrusted content brought into the run, the kind of value (email, url, host, path,
digits, string) and the record that brought it — never the value.
Detection (format 1.2, policy): under a policy with sequences
or limits, policy.decision carries detect{sequences: [{id, step}], limits: [{id, count, window_start}], state, rule_ids?, sums?}. Signer records in the run whose record fired them: incident.opened{incident_id, trigger, rule_ids, scope: {kind, id}, cites: [{run_id, run_seq}], mode, actions, would}, incident.updated{incident_id, cites} (another
match, at most once per window; for a session, owner or principal scope possibly in another run of that scope than
the one that opened it) and incident.capped{cap, max, trigger, reason}.
Containment (format 1.2, incidents): an enforced incident.opened (mode: enforce) lists the
actions taken (hold, quarantine, notify; none while paused, with reason) and in_flight: the
calls {run_id, tool_call_id, decision_id} allowed (or approved and consumed) and not completed then. Triggers
operator (an operator's run_hold or run_quarantine) and detector_unavailable (the detector could not vouch for the
cited decision) join the detection's. In each run the response reaches: run.held{incident_id, scope, by, reason, in_flight?, inherited?, inherit_until?} or run.quarantined{...the same}, after an approval.expired{reason: hold | quarantine} for each of its live approvals; run.resumed{incident_id, by, person, reason?, break_glass?, held_by} ends that incident's hold; a run held by several incidents (a run.held each) stays held by those held_by
names.
A held run's calls are policy.decision{decision: ask, rule_ids: [..., TK-HOLD], incident_id}, their approvals carry
incident_id (approval.request and the person's approval); a quarantined run's are decision: deny, rule_ids: [TK-QUARANTINE] and it closes with run.closing{reason: quarantine}. The call whose decision fired a hold gets a
second, signer-written decision right after the response. In the tenant's signer run: incident.resolved{incident_id, by, person, reason?, break_glass?, resumed}, incident.response{enabled, by, reason?} and
identity.suspension{identity, state: suspended | enforced | lifted, by, reason?}.
Content vault (format 1.1, privacy): content.accessed{principal, run_id, seqs, purpose} records that the signer returned the retained content of records seqs (run_seqs) of run run_id to
principal, for purpose; content.erased{run_id, reason, by} records that it deleted that run's retained content.
Both are signer records (source: signer) in the run tracekit/signer of the tenant they concern, never in the run
itself, whose records stay unchanged. They hold no content. A bundle with either needs verifier_min_version 1.1.0.
tracekit why findings (format 1.1, why): a replay run's run.registered carries
replay_of (the run it replays) and investigation, and the signer does not reconcile it; why.finding{target, intervention, n, p_with, p_without, effect, interval: [lo, hi], verdict, replay_runs: [{run_id, arm: control|ablated, hit}], models, rung, off_tape, investigation, looks, family, min_effect, applied, stopped, uncounted?: [{run_id, reason: ranking|budget}]}, written through the why_finding RPC in a findings run (run.registered analyzes),
records a finished test. A bundle of a run with its findings and replay runs (tracekit export --v2 --run R --with-findings) holds those runs too; the verifier checks that every replay run a finding names is in the bundle,
once, as a replay run of the analysed run in the finding's investigation, holding its model calls as source: gateway
model.exchange request and response pairs (one pair for rung 1 and for ranking); that an uncounted run is budget
only in a finding stopped: budget; that with a target of tool clauses each hit has a response tool use those
clauses match and, with tool clauses alone, each miss has none; that the arms and outcomes count to its n,
p_with, p_without and effect; that interval is the Newcombe interval of those counts at z for looks ×
family comparisons (Bonferroni), rounded to 4 places, with lo ≤ hi; and that verdict follows from it, applied
and min_effect (inconclusive when stopped is budget); and that every replay run in the bundle of an
investigation with findings is named by one (findings, FAILED otherwise). Replay runs of an investigation with no
finding are a replay runs warning. In any other bundle (a findings run alone, a run-set) a finding whose replay runs
are absent is not counted (findings counted, a warning). A bundle with either needs verifier_min_version 1.1.0.
Signed findings (format 1.4, findings): tracekit analyze --v2, run by an operator identity signer.yaml
analyzers lists, writes each finding through the review_finding RPC in a findings run (run.registered
analyzes; refused in any other run) as review{reviewer, verdict, cites: [{run_seq, hash}]}. cites are records of
the analysed run, by run_seq and record hash: the signer refuses a finding (invalid_request) unless each is.
The verdict holds no text of the run, only {kind: finding, rule, title, severity: info|low|medium|high|critical, tier?: gateway|harness|transcript|sdk (the text it relied on; harness: a prompt the hook recorded and the transcript agree on), order?: signed_before|transcript_order|after (that text against the call), redacted?, lifted?: true (TK-X204: a later request named what the constraint kept away, so the finding is downgraded), digests?: [sha256:...], alignment?: ok|drift|misaligned, reason?: <a reason code> (TK-X210: the reviewer model's answer), asserted?: {model, version, prompt_hash} (the reviewer model and prompt the analyzer says it asked), families?: {reviewer, agent: [...]} (model families: anthropic, openai, google, meta, mistral, deepseek, qwen, xai, or a null reviewer), window?: {grace_s, skew_s} (TK-X206, TK-X207: the window effects were joined to calls with)}, or once per
pass {kind: coverage, rule: TK-X200, title, severity: info, checks: {<rule>: {checked, found, not_checkable?: [{reason, n}]}}, partial?, reasoning?: {full, summary, withheld, none}}. title is the rule's fixed title
(rpc_schema.FINDING_TITLES), written by the signer; digests are copied from the cited records, never a hash of
plaintext. The finding's author is the findings run's run.registered identity, as the signer measured it;
reviewer (the tool and its version) is the analyzer's claim. Excerpts, paths and reasons are shown when a person
reads the cited records (content_get), never signed. A review without cites (a v1 judge verdict carried over,
format 1.0) is imported, and never checked. A bundle with cites needs verifier_min_version 1.4.0.
Live constraints (format 1.4, experimental, policy): what a person's request said not
to do in the rest of a run, as a client of the run reported it (constraint_add; the Claude Code hook at
UserPromptSubmit): constraint.added{kind: keep_away|only_change|read_only|no_push|no_network, object_commitment?, salt_id?} (source: the run's). object_commitment, for keep_away and only_change only, commits (§2, under the
record's salt_id) to "sha256:" + hex(SHA-256(UTF-8 bytes of the file's name as constraints compare it)): quotes
(', ", `) around it dropped, its base name (after the last / or \), trailing dots dropped, lower-cased
(tracekit.constraints.norm); the name is never in the record. From then on the signer raises each call of the run
that breaks a constraint to ask and adds the rule
id TK-CONSTRAINT to its policy.decision; it never lowers a decision. Every policy.decision whose rule_ids hold
TK-CONSTRAINT must be an ask after a constraint.added in its run (constraints fails otherwise). A constraint
equal to one the run has (the same kind; for a file, the same name) adds no record. A run takes 32 file constraints
(keep_away, only_change; the other kinds are always added): past them, a new one is a capture.gap{kind: constraint_refused, reason} (source: signer, a gaps warning), so a constraint the run had no room for is never
dropped unrecorded, and the signer raises every later call of the run that writes to ask with TK-CONSTRAINT. A
directory's commitment is to its name and a slash (legacy/), and a call breaks it with a write under it. A bundle with constraint.added or constraint_refused needs
verifier_min_version 1.4.0.
Completeness (format 1.2, limits): gaps for what the signer saw decided or reported
and then never heard about. All are capture.gap records with source: signer.
kind |
Where, when | Fields |
|---|---|---|
decided_not_completed |
in the run, at run.final: each open decision that let its call run (allowed, or asked with the call's approval consumed) and has no complete, unless a later decision of the call completed (for the decision a consumed approval names: with the approved arguments); a call a gateway or executor holds (via) gets held_call_unresolved instead; at most 100, then one counting the rest |
tool_use_id, top-level tool_call_id and attempt; the last one missed_events
|
concurrent_run |
in the newer run, for a run bound to a harness process: another run of the same owner, tenant and harness process, in another session, was open when it registered, and both have recorded an event since |
run_id: the other run |
late_activity |
in the tenant's run tracekit/signer, with the refusal summaries: writes refused because their run was final (not a close_run, nor each identity's and stream's first one) |
run_id: that run; missed_events: how many; from_ts, to_ts
|
client_counter_gap |
also at close_run with last_client_seq: the values of a stream the closing identity wrote (or that never reached the signer) not received by then (the reason names that identity; values up to there that arrive later are recorded with no second gap). A gateway's report about a run already final goes in the tenant's run tracekit/signer
|
missed_events, top-level stream; in tracekit/signer, run_id: the run |
tailer_lost |
also when a transcript tailer (an identity a run was delegated to with the tailer_heartbeat grant) makes no call for 90 s while its run is open, counted from the delegation |
— |
tailer_redelegated |
in the run, when a second tailer token is delegated for it | — |
observer_lost |
in each open run an egress observer (or a kernel sensor's import, format 1.4) covers, when it makes no observe_effect call for 90 s, or another process of it (instance) takes over; and when an import reports a loss (OpenShell's import: in the one run a loss names, when it names one) |
sensor (format 1.4): tetragon, falco or openshell, when it is a sensor's import |
effect_outside_run |
in the tenant's run tracekit/signer: an egress observation no open run holds (after its run.final and observe_grace_s, a token the signer didn't issue for that run, an identity with no open run) |
run_id: the run the client named or it was attributed to, when there is one |
Effects (format 1.2, egress): an egress observer outside the agent reports the hosts each run reached
(observe_effect, its own identity's never-default grant); the signer writes effect.observed{observer, kind: net, host, port, bytes_out, bytes_in, connections, first_ts, last_ts, attribution: token|identity, explained_by?: call|explains} (source: signer, one per host, port and minute) in the run, and for a host no recorded http or model
call nor the policy's explains covers, once per host and run,
reconcile.unexplained_effect{layers: [L6], observer, kind, host, port, detail}, counted in run.final's
coverage.unreconciled. run.registered carries the deployment's posture{network: enforced|open, tool_calls: enforced|open, gateway_mandatory, attested: false} when signer.yaml sets posture or gateway_mandatory.
run.final carries matrix: per action class (model_calls, tool_calls, network, processes, services)
{status: enforced|observed|reported|none, layers, asserted?}, from the sources of the run's records, the posture and
the observers that covered the run throughout.
A run bound to a harness process that registers with parent writes a run.child record (source: signer,
data: {run_id}: the child) in its parent run.
A gap in a tenant's run tracekit/signer (late_activity, a gateway's client_counter_gap) has a leaf in that
tenant's registry (§7), so a run-set proves it; the verifier counts it with the tenant-level gaps. A
reconcile.hook_missing for a tool use the LLM gateway reported is written as soon as a gateway request sends its
result back with no decide, not only before run.final. A model.exchange tool use with executed_by: provider
carries an args_commitment when the provider returned its input; run.final carries provider_executed, the number
of such tool uses, which no decide covers and which are never reconcile.fabricated (with gateway_mandatory, only
those the gateway reported), and paired_by_args, the number of calls reconciled by pairing a decide with a tool use
of the same model turn by their arguments when the adapter had no tool-call id (never across turns, never when two
could match). A bundle with any of these kinds, fields or run.child needs verifier_min_version 1.2.0. A
model.exchange response with said_only: true (format 1.4) holds what the model said without the calls it asked for
(a harness whose hooks name no call: Gemini CLI, Cursor); reconciliation reads no call from it, so it never makes a
decide reconcile.fabricated.
Verified tool results (format 1.3, gateway): when signer.yaml sets
verify_tool_results, an LLM gateway's request record is checked against the run's tool results. For each tool result
the request sends, the gateway gives the digests of the values it may have been recorded as; the signer computes their
commitments under the salts of that call's tool.results (§2). A result that matches none of a call's ok results that
carried a result (redaction having changed none of them), or a result sent for a call the run allowed and has not
completed, is a reconcile.result_mismatch{layers: [L2, L3], detail} (source: signer, tool_call_id), once per
call, counted in run.final's coverage.unreconciled. A result the request names (tool_results_sent) that the
gateway gave no digests of (past what it digests per request, or a gateway older than 1.3), for a call with such a
result to compare, is a capture.gap{kind: result_unchecked, tool_use_id, reason} (source: signer, tool_call_id),
once per call. A bundle with either needs verifier_min_version 1.3.0.
Kernel sensor events (format 1.4, experimental, Tetragon, Falco):
an import of a kernel sensor's export (observe_effect with sensor, the same grant) reports what an agent's
processes did, and names in every call the identities its configuration maps processes to (callers): it covers the
runs of those identities registered while it is alive, and no other run. The signer writes
effect.observed{observer, sensor, kind: exec|exit|connect|file, ts, pid, binary, exec_id?, parent_exec_id?, args_commitment?, salt_id?, status?, signal?, host?, port?, path?, tool_call_id?, attribution} (source: signer,
one per event) in the run it is attributed to. args_commitment commits to "sha256:" + hex(SHA-256(UTF-8 bytes of the arguments string as the sensor reported it)) (§2). tool_call_id is the id the process's environment carried, the agent's claim, recorded as it
came: the signer never takes it as explaining the effect (tracekit analyze --v2's TK-X206 checks it). An event no
open run of its callers holds is an effect_outside_run gap, as for the egress observer. A loss the import reports (lost) is an
observer_lost gap with sensor in each run it covers, or in the tenant's run tracekit/signer when it covers none,
and ends that coverage; so do 90 s of silence and a restart. run.final's matrix says processes: observed with the sensor as
its layer when an import covered the run throughout; an import's coverage never makes network observed. A bundle
with these records needs verifier_min_version 1.4.0.
OpenShell's OCSF events (format 1.4, experimental, OpenShell): the
OpenShell middleware's import is such an observer with sensor: openshell and its own identity as its one caller (the
signer refuses any other), so it covers only the runs it registered, attributing each event by the sandbox's run
token. Its effects are connect (denied: true when OpenShell's policy blocked it), exec and exit. A change of
the sandbox's policy or configuration is an ext record (§9) {name: openshell.policy, observer, ts, change, policy_hash?, by?, detail_commitment?, salt_id?, attribution} (source: signer): change OpenShell's name for it
([a-z0-9_.-]{1,64}), policy_hash and by OpenShell's, detail_commitment committing to "sha256:" + hex(SHA-256(UTF-8 bytes of OpenShell's message)) (§2). A loss it reports for one run is an observer_lost gap (with
sensor) in that run only, which ends no coverage. Its coverage never makes processes observed: OpenShell reports the processes
it starts, not each one. A configured gateway may observe the runs it registered for its clients (it is not their
agent).
Non-critical fields and records (format 1.4): any record may carry ext, an object of names to strict JSON values,
and a record of type ext holds data: {name, ...} with any other fields. They are signed, chained and included like
every record; a verifier that doesn't interpret a name says so and still verifies the bundle (§9).
ext.amended (format 1.4, gateway): on an LLM gateway's model.exchange request
record, the JSON pointers (RFC 6901) of the fields the gateway changed in
the client's request before sending it, to have the provider show the reasoning the request has the model do:
["/thinking/display"] (Anthropic Messages, set to summarized) or ["/reasoning/summary"] (OpenAI Responses, set to
auto). The record's content_digest (and its vault body) is of the request as sent. The signer writes it only for a
gateway (model_event with caller), on a request record.
A record is one line of JSON:
{"v": 2, "event": <event>, "hash": "sha256:<hex>", "alg": "ed25519", "kid": "sha256:<hex>", "sig": "<base64>"}
- exactly these six keys;
vis the integer 2; -
hash="sha256:" + hex(SHA-256(JCS(event))); -
kid="sha256:" + hex(SHA-256(DER SubjectPublicKeyInfo)), so it binds the key's algorithm too; -
sig= standard base64 of the signature over the signature message.
The signature message is JCS of an object holding the record's position fields, its alg, kid and hash, and the
domain tag "t":
JCS({alg, kid, log_id, seq, prev_hash, tenant, run_id, run_seq, run_prev_hash, hash, "t": "tracekit.record.v2"})
Each signature purpose has its own tag, so a signature made for one purpose never verifies as another. Only record
is used today; the others are reserved:
{"t":"tracekit.record.v2"}
{"t":"tracekit.checkpoint.v2"}
{"t":"tracekit.approval.v2"}
{"t":"tracekit.cert.v2"}
{"t":"tracekit.retire.v2"}
{"t":"tracekit.export.v2"}
Worked example: the first record of tests/vectors/sig_v2.jsonl (record-signer-epoch) has the signature message
{"alg":"ed25519","hash":"sha256:db5045e1f08ce84b3f4af68706546c4db460adbef48568a7206ba5cc73f73dc6","kid":"sha256:06e3fd8fda29bb60ab59557de61edb0aecdb231134be30e75b455f8e1b792fa9","log_id":"0123456789abcdef0123456789abcdef","prev_hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","run_id":"run-1","run_prev_hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","run_seq":0,"seq":0,"t":"tracekit.record.v2","tenant":"acme"}
Algorithms. alg must be in the verifier's pinned allow-list (algs in the trust config) and be the key's own
algorithm (ed25519 for an Ed25519 SPKI); a mismatch is algorithm confusion and fails. Ed25519 is verified under strict
rules, identical on every backend: S < L, a canonically encoded public key that is not of small order, and the
cofactorless equation [S]B = R + [k]A (crypto.verify_v2; vectors ed25519-* in sig_v2.jsonl).
Key epochs and positional retirement. Record keys are declared by signer.epoch records in the log itself. A key
is valid from the seq of the signer.epoch that declares it up to last_seq of a later key.retire{kid, last_seq}
for it, inclusive; with no retirement, from then on. A record is valid only under a key valid at its own seq. A record
signed by a retired key after its last_seq fails, and the report names the retired key. The signer writes one
signer.epoch as the log's first record; with a record-key issuer, a signer.epoch and the previous key's
key.retire (last_seq: the record before the epoch) at every start and before the certificate expires
(issuer.md).
Each log is an RFC 6962 / RFC 9162 Merkle tree (tracekit/merkle):
leaf hash = SHA-256(0x00 ‖ data)
node hash = SHA-256(0x01 ‖ left ‖ right)
empty tree = SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The record tree's leaf seq has as data the 32 raw bytes of record seq's hash. For record-signer-epoch:
leaf hash = SHA-256(0x00 ‖ db5045e1…3dc6) = 252e000747132185f241d03274ef087f1eccb5d708dc18b49c9d990f21059060
Inclusion and consistency proofs are verified as in RFC 9162 §2.1.3.2 and §2.1.4.2.
The signer stores tree hashes as tiles, in the tlog-tiles layout: tile (level L,
index N, width w ≤ 256) holds w consecutive hashes at tree height 8·L, starting at hash 256·N, as concatenated
32-byte hashes, at <level>/<index>.<width> (merkle/tiles.py). Full tiles never change. Tiles are storage, not
evidence: a bundle carries proofs, not tiles.
A checkpoint is a C2SP signed note in the
tlog-checkpoint format, cosigned per
tlog-cosignature (all v1.1.0; format/checkpoint.py):
<origin>\n<tree size>\n<base64 root hash>\n\n— <key name> <base64(key id ‖ signature)>\n...
- The log key (
keys/log.key, Ed25519, stable, signs notes only) is named after the origin (defaulttracekit.local/<log_id>). Its line is type 0x01: Ed25519 over the note text (everything up to and including the newline after the root). - A witness cosigns with type 0x04:
8-byte big-endian timestamp ‖ Ed25519("cosignature/v1\ntime <t>\n" + text). -
Key id = first 4 bytes of
SHA-256(name ‖ 0x0A ‖ type ‖ public key). Keys are pinned as vkeys:<name>+<hex key id>+<base64(type ‖ public key)>. - A verifier ignores signature lines of keys it does not pin (unknown types too) and rejects the note when a pinned key's signature fails, when one key signs twice, or when no pinned log key named after the origin signed it. Limits: 1 MiB note, 64 signature lines, canonical base64, tree size without leading zeros.
Worked example (tests/vectors/c2sp_checkpoint.json): note text
tracekit.example.org/log/0190f3a0-test
13
Mi7CJiZLruMIePTlzHPx4mYt0B1o8dZ+0VjPECnCnOY=
signed by tracekit.example.org/log/0190f3a0-test+070965d6+AZsfhHkc/HZjBFhU04netcqVRJ2HXydqg4QLBgqznPxx (key id
070965d6) and cosigned by witness.example.org/w1+257cc7f1+BGG3op9v3HIXXDPBmFc/0CioJHhGCXc+pyvvFPlBZdYO.
The signer signs a record-tree note after each run.final, on checkpoint_nudge, every 10 s while the tree grows and
on close, then one note per tenant registry that grew. How witnesses are run and pinned: witnesses.
With log_key: {slh_dsa: {file: keys/log-slh.key}} in signer.yaml, every stored note (record tree and registries)
carries a second log line after the Ed25519 one, from a stateless hash-based key: SLH-DSA-SHA2-128s
(FIPS 205), pure mode, empty context, over the same note text
(format/slh_dsa.py, pure Python, no native dependency). Records stay Ed25519; a record is covered long-term through
the tree root the SLH-DSA line signs.
- The line has type 0xff and the same key name as the log line. Its "public key" is
"tracekit/slh-dsa-sha2-128s" ‖ PK.seed ‖ PK.root(26 + 32 bytes), so the key id isSHA-256(origin ‖ 0x0A ‖ 0xff ‖ "tracekit/slh-dsa-sha2-128s" ‖ pub)[:4]and the vkey<origin>+<hex key id>+<base64(0xff ‖ "tracekit/slh-dsa-sha2-128s" ‖ pub)>. The signature is 7856 bytes (a line of about 10.5 KB). - Never sent to witnesses (litewitness caps requests at 10 KiB, omniwitness at 16 KiB) or to Rekor: they get the note text and the Ed25519 line only. It is in the stored note and in bundles.
- A verifier that does not pin the hybrid key ignores the line, so old verifiers read new notes. One that pins it
(
tracekit signer trustpins both keys,tracekit signer vkeyprints both) requires both a valid Ed25519 line and a valid SLH-DSA line from the origin's pinned keys; a note without the line, or with either line bad, failscheckpoint. Notes signed before the key was configured have no line and fail against a trust config that pins it. - Cost: signing takes about 1 s of CPU per note (pure Python; about 0.8 s on an Apple M-series core), once per tree that grew, at most once per second per tree; verifying takes about 1 ms. The signer signs notes off the writer thread.
Worked example (tests/vectors/slh_dsa_note.json): the note text above, signed by the Ed25519 key above and by
tracekit.example.org/log/0190f3a0-test+b412f80c+/3RyYWNla2l0L3NsaC1kc2Etc2hhMi0xMjhzP2n4dHiz4830nh9Ilj+5EtFbGqwUwJGvStS72PP3Q7g= (key id b412f80c).
Each tenant has a registry log, a second tree whose leaves are fixed-width (89 bytes, format/registry.py):
type u8 ‖ H(tenant_salt ‖ run_id) 32B ‖ record log_id 16B ‖ seq u64 big-endian ‖ record hash 32B
| type | record |
|---|---|
| 1 | run.registered |
| 2 | run.final |
| 3 | log.closed |
| 4 | key.retire |
| 5 |
capture.gap (tenant-level only) |
| 6 |
trace.tamper (tenant-level only) |
run.registered and run.final get a leaf in their run's tenant's registry; log.closed, key.retire and
tenant-level gaps get one in every tenant's registry. tenant_salt = HMAC-SHA256(registry salt, tenant), H is
SHA-256. The registry's checkpoint origin is <record log origin>/registry/<first 32 hex of SHA-256("registry " ‖ tenant_salt)>, signed by the same log key, so the origin never carries the tenant's name. Worked example (registry
salt = 32 zero bytes, tenant acme, log origin tracekit.example.org/log/1):
tenant_salt c9a7e476bacbbf87e60c30ba2612595b5a1a0bd1dcb1ba8dbd37c30be1fec8a8
H(salt ‖ "run-1") 34bc0067fd0081b5c0324ba1e799cd769f8542cd42b0b2c699751ce63fb9d00f
registry origin tracekit.example.org/log/1/registry/62d28187e34b4d26bb63c6d6f490e8b3
A run-set is the range of registry leaves a..b−1 between two registry checkpoints. It proves which runs of the
tenant were registered and finalised in that window: a run deleted, a second run.final, or a withheld key.retire
shows as a missing or extra leaf. A bundle with a run-set carries the tenant salt, so its holder can test guessed run
ids against this tenant's leaves; other tenants' leaves stay opaque. In registry/run-set.json (§9), from and to
are integer tokens: a number with a fraction or exponent (4.0, 4e0), or any other type, makes the run-set
INCOMPLETE.
With anchors.rekor configured, the signer anchors record-tree notes in Rekor v2 at most once an hour: a
hashedrekord v0.0.2 entry over the note text and the log's signature line, signed with a P-256 publishing key
(keys/rekor.key), plus an RFC 3161 timestamp of the same bytes, which gives the anchor its time. The verifier checks
it offline against a pinned Sigstore trusted_root and publishing key (tracekit.anchor.rekor2). Setup:
witnesses.
A bundle is a zip. The manifest is an index and is never trusted: every file it lists must hash as listed, and the bundle may hold nothing else.
manifest.json {"format": "tracekit.bundle.v2", "verifier_min_version": "1.2.0", "verifier_version": "1.2.0",
"files": {name: sha256 hex}}
runs/<id>.jsonl one run's records in run_seq order; id = first 32 hex of SHA-256("<tenant>/<run_id>")
runs/<id>.part-0001.jsonl from 1.4.0, a run whose records pass the 64 MiB a file limit: its records in parts
runs/<id>.part-0002.jsonl numbered from 0001 with four digits, each split at a record and at most 64 MiB (below)
keys/records.jsonl every signer.epoch and key.retire record the checkpoint covers
proofs/records.json {"checkpoint": "checkpoints/<size>.note", "tree_size": n,
"inclusion": {"<seq>": [base64 hash, ...]}}: inclusion of each run's first and last record,
every key record, and every record a registry leaf points to
checkpoints/<size>.note the record-tree note the proofs are against
rekor/<size>.json its Rekor v2 TransparencyLogEntry, when anchored
tsa/<size>.tsr its RFC 3161 timestamp response, when anchored
policies/<sha256>.json the compiled policies a verifier checks the records against, named by the SHA-256 of
their bytes (their policy_hash)
A run-set bundle adds:
registry/run-set.json {"tenant_salt": base64, "from": a, "to": b (integer tokens), "consistency": [base64 hash, ...],
"leaves": [{"leaf": base64, "inclusion": [base64 hash, ...]}, ...]}
registry/records.jsonl the record each leaf points to, in leaf order
checkpoints/registry-<n>.note the registry notes at a (none when a = 0) and b
runs/ every run whose run.final is in the range, and the selected run, if any
JSON lines files end in a newline, one strict JSON text (§1) per line, each line at most 1 MiB and at least 256 bytes (no signed record is shorter: its hash, signature, ids and timestamp alone are longer), so a bundle's lines are counted and measured before any is parsed and can't outnumber 512 MiB / 256. Hashes and keys in
bundle files are standard base64 with padding (checkpoint notes and vkeys: canonical base64, §6). Zip limits: at most
10,000 entries, 64 MiB per entry, 512 MiB in total; entry names match [A-Za-z0-9_-][A-Za-z0-9._-]*(/[A-Za-z0-9._-]+)*
with no . or .. segment; no symlinks; no duplicate names. A bundle carries no code and no trust configuration.
A bundle has one zip layout, so every reader sees the same entries; anything else is UNUSABLE BUNDLE:
- the entries, in central-directory order, start at offset 0 and follow each other with no gap or overlap: each is a local header, its name and its data, and the next starts where that data ends;
- the central directory starts where the last entry ends and ends where the end-of-central-directory record starts (offset + size = the record's offset), and the record is the file's last 22 bytes: no archive comment, no bytes before the first entry or after the record;
- the record is for disk 0 only, and both of its entry counts equal the number of central directory entries;
- no zip64 record or locator, and no extra or comment field in any header;
- each local header repeats its central entry: name, general-purpose flags, method, CRC-32 and both sizes;
- general-purpose flags 0 (so no encryption and no data descriptor); method 0 (stored, with equal sizes) or 8 (deflate);
- each entry's data is exactly its stated size and matches its CRC-32.
tracekit.bundle_v2 writes this layout (Python's zipfile writing a seekable file), and refuses a bundle past the
zip limits above, which no verifier would read.
A run in parts (from 1.4.0). A run whose records fit 64 MiB is one file, runs/<id>.jsonl, as before. A longer
one is written as runs/<id>.part-0001.jsonl, runs/<id>.part-0002.jsonl, ...: the run's file cut between records,
each part as full as the limit allows, so its parts, read in order, are that file, and a verifier checks them as it
(chain, signatures, inclusion of the run's first and last records). The proofs carry no inclusion of a part's own
first or last record. A run cut back, at a part boundary or anywhere, reads open (VERIFIED TO HEAD n), never
VERIFIED: the proofs file isn't signed, and from a run's records an inclusion proof of the record now last can often
be computed, so only the run's run.final or a run-set proves a run complete. A run can then hold up to the bundle's
512 MiB (about 190,000 tool calls of the server pack). Anything else about the parts fails the run parts line
(verdicts): a part missing between part-0001 and the last (a gap in the numbers), a name outside that
numbering (part-0000, part-02, five digits), a part with no records, a part whose first record's run_prev_hash
is not the hash of the last record of the part before it (parts out of order, an extra or repeated part), or the run
both in one file and in parts. Its last part missing leaves the run open, and fails inclusion while the proofs
include no proof of the record now last. A part over 64 MiB is, as any file, UNUSABLE BUNDLE. A bundle with a run in
parts declares run parts (below), so a 1.3 or older verifier, which would read each part as a run of its own,
reports it UNVERIFIABLE (needs tracekit >= 1.4.0).
verifier_min_version is the newest version in the version table (VERSIONS in tracekit/bundle_v2.py) whose
critical entries a bundled record uses, else 1.0.0, and at least 1.4.0 when a record has an ext field or is of type
ext. verifier_version (from 1.4.0; advisory, like the whole manifest) is the newest version whose entries, ext names
among them, the records use: the version that checks everything in the bundle. tracekit export --v2 prints both, and
which records raised verifier_min_version (raised_by). A row is frozen once its version's first release candidate
ships (test_shipped_rows_are_frozen): an addition after that goes in a new row. Each version lists what it added:
record types, data fields per record type, and enum values per (record type, field), gap and tamper kinds among
them, all critical; from 1.4.0 the bundle layouts it reads, critical too (layout; raised_by names them); and from
1.4.0 the ext names it interprets, which are not:
| Version | Record types | Data fields | Enum values | Ext names (non-critical) |
|---|---|---|---|---|
| 1.1.0 |
input.observed, content.accessed, content.erased, why.finding
|
policy.decision: provenance, provenance_state; run.registered: replay_of
|
— | — |
| 1.2.0 |
run.child, approval.escalated, approval.revoked, approval.suggested, approval.agents, incident.opened, incident.updated, incident.capped, run.held, run.resumed, run.quarantined, incident.resolved, incident.response, identity.suspension, effect.observed
|
run.registered: parent_run, session, posture; capture.gap: run_id; run.final: provider_executed, paired_by_args, matrix; policy.decision: via, caller, detect, incident_id; approval.request: agent, via, incident_id, passkey; tool.result, input.observed: via; approval: approver_kind, asserted, spent, not_before, consume_by, incident_id; approval.expired: reason; reconcile.unexplained_effect: observer, kind, host, port
|
capture.gap kind: decided_not_completed, late_activity, concurrent_run, tailer_redelegated, reask_after_reject, held_call_unresolved, observer_lost, effect_outside_run; run.closing reason: quarantine
|
— |
| 1.3.0 | reconcile.result_mismatch |
— |
capture.gap kind: result_unchecked
|
— |
| 1.4.0 | constraint.added |
effect.observed: sensor, denied; review: cites; model.exchange: said_only; capture.gap: sensor
|
capture.gap kind: constraint_refused
|
amended, openshell.policy; and the slot itself (ext fields, ext records: critical) |
Layouts (critical): 1.4.0 adds run parts, a run in parts (above).
Every v2 definition but review's data and its verdict, error's data, run.start's agent and trace.tamper's
before and after (open since format 1.0) and the ext record's (open by design) is closed (additionalProperties: false), so an older verifier would fail a record that uses a newer critical entry; the version the manifest declares
makes it report UNVERIFIABLE (needs tracekit >= x) instead, never FAILED, and from 1.4.0 the bundle readable line
lists the commands that verify the bundle (§11 step 2). The open definitions are why the manifest's version matters
for review.cites: a 1.3 verifier given a signed finding under a manifest edited down to 1.3.0 passes its record's
schema and checks neither its citations nor its author, and prints no Findings: line. What it prints stays true, the
records being signed and included as any; the findings are left out, unchecked, so verify a bundle with signed
findings with 1.4.0 or later.
Criticality. From 1.4.0, an addition an older verifier can leave unread goes in the non-critical slot rather than a new critical row, so a verifier from 1.4.0 on keeps verifying later bundles:
-
exton any record:{name: value}, each name[a-z][a-z0-9_.-]*and at most 64 characters, each value any strict JSON (§1) within the 1 MiB line limit; and the record typeext,data: {name, ...}with any other fields. - The signer decides what is critical by where it writes it: a closed field is critical,
extis not. That choice is inside the signed record; the manifest, never trusted, only names versions. - A verifier from 1.4.0 checks every record's schema, signature, chain and inclusion whatever its
extholds, so an ext value edited, added or removed failssignatures, and anextrecord dropped failsrun chain. It interprets the ext names its own rows list, each on lines of its own; every other name gets anot interpretedwarning (n record(s) carry ext.<name>: ..., and; tracekit >= <verifier_version> checks itwhen the manifest'sverifier_versionis newer than the verifier; the first 20 names in code-point order, and one more line,n more ext name(s), ..., for the rest), exit 0 (3 with--strict), and the integrity staysVERIFIED: every check that verifier implements passed. -
The rule. A name may go in
extonly if every line an older verifier prints stays true without its interpretation: with it off, the report is the full report minus that name's own lines, plus itsnot interpretedline (test_an_ext_interpretation_adds_only_its_own_lineschecks each name a row lists). -
amendedis non-critical: without it every line stays true (a request record's commitment is to the request as sent either way); a verifier that reads it adds agateway amendmentsline. -
openshell.policyis non-critical: it is OpenShell's report of its own configuration, beside every record a verifier reads; without it every line stays true, and a verifier that reads it adds anopenshell policyline. -
review.citesandeffect.observed.sensorstay critical: a verifier that passed over a signed finding's citations would label itimported, a false line; and a sensor's effects are records of what the agent's own processes did, in its run, which a verifier must read to verify that run. So doescapture.gap.sensor: a 1.3 verifier counts everyobserver_lostas an egress observer gone silent, a false line for a sensor's reported loss, so it reports such a bundleUNVERIFIABLE (needs tracekit >= 1.4.0). So doeseffect.observed.denied: read without it, a connection OpenShell blocked would read as made.
tests/test_released_verifiers.py runs each released Python verifier (from its git tag) on bundles of today's signer:
those that declare its version or lower verify there with the same lines as here, the others are UNVERIFIABLE.
The verifier pins its own trust, never the bundle's (verify.v2.load_trust):
{"logs": ["<log vkey>", "<hybrid SLH-DSA log vkey, optional>"],
"witnesses": [{"vkey": "<cosignature vkey>", "class": "public|customer|tracekit|operator"}],
"algs": ["ed25519"],
"witnesses_required": 0,
"rekor": {"trusted_root": {}, "publishing_key": "<base64 SPKI>", "class": "public"},
"monitors": [{"vkey": "<monitor vkey>", "class": "public|customer|tracekit|operator", "max_age_s": 3600}],
"issuers": [{"vkey": "<issuer vkey>", "issuance_log_vkey": "<issuance log vkey>"}],
"analyzers": ["<scheme:subject>"]}logs and algs are required and non-empty; witnesses, witnesses_required (default 0, an integer token),
rekor, monitors (monitor reports the verifier is given), issuers (record-key issuers,
issuer.md) and analyzers (the identities, scheme:subject as run.registered records them, whose
signed findings you take as independent: your operator's tracekit analyze --v2, never the agent's) are optional. No
other key is allowed. tracekit signer trust -o trust.json writes one for a signer; an auditor should build theirs
from independent sources (auditor guide).
tracekit verify bundle.tkb --trust trust.json (verify.v2.verify). Each step adds report lines; the meaning of each
line and verdict is in verdicts.
- Trust config. Parse strictly; check the keys and types of §10; parse every vkey. Failure: exit 2.
-
Read the zip under the limits of §9. Parse
manifest.json;formatmust betracekit.bundle.v2andverifier_min_versiona version number (one to three dot-separated runs of ASCII digits) not above the version of the format the verifier implements, elseUNVERIFIABLE (needs tracekit >= x), exit 2, thebundle readableline listing commands that verify it with the user's paths:uvx --from "tracekit-ai>=x" tracekit verify <bundle> --trust <trust>,npx -y @cygnuxlabs/tracekit@latest verify <bundle> --trust <trust>(the TypeScript verifier lists it first) and the browser page (§13).verifier_versionis advisory: it only names the version onnot interpretedlines (step 10). Failure:UNUSABLE BUNDLE, exit 2. -
Manifest. The listed files and hashes must equal the files present, exactly (
manifest). -
Checkpoint. Open the note named by
proofs/records.json(§6) with the pinned log keys (both lines when a hybrid key is pinned) and witness keys; its tree size must equaltree_size(checkpoint). Count the pinned cosignatures againstwitnesses_required(witness quorum). If the trust config pinsrekorand the bundle hasrekor/<size>.json, verify the anchor (rekor anchor). -
Keys. Walk
keys/records.jsonlin increasing seq: each must be asigner.epochorkey.retirerecord included in the checkpointed tree; each declared key'skidandalgmust match its SPKI; each record must verify (§4) under the keys valid at its seq; akey.retiremust name a declared key and sets itslast_seq. All records must share the first key record'slog_id. A key withcertis declared only if its certificate verifies under a pinned issuer, for that key andlog_id, in the issuer's cosigned issuance log (issuer.md). -
Runs. A bundle holds exactly one run, one run with its findings and replay runs (
--with-findings), or any number with a run-set; a run is itsruns/<id>.jsonlfile or its parts read in order (§9), whose layout therun partsline checks when there are parts, and no two runs share a tenant and run_id (run chainfails otherwise: a run's records under a second name would count twice). For each run, every record must: verify under the keys valid at its seq (§4); be a schema-validtracekit.event.v2event of thislog_id; haverun_seq= its index,run_prev_hash= the previous record'shash(zeros first), the first record's tenant and run_id, a seq above the previous record's; and follow norun.final. The run's first and last records must be included in the checkpointed tree. -
Run-set (with
registry/run-set.json). Open the registry notes ataandbwith the pinned log key renamed to the registry origin of §7 and the pinned witnesses; each needswitnesses_requiredcosignatures, and caps the assurance level by the same rule as the checkpoint; check consistencya → b; requireb > 0and exactlyb − aleaves, each included in the registry tree atb, each pointing to a record inregistry/records.jsonlwith that hash, seq, type, log_id and run hash, included in the record tree. Onerun.finalper run; every run final in the range present in the bundle from itsrun.registered(when in the range) to thatrun.final; every bundled run but one the target of a leaf; every bundled run of the tenant the range's run leaves name (the run-set line names it). Everykey.retireleaf in the range must be among the key records. Alog.closedin the range must be the checkpoint's last record, or later records were added after the log closed. -
Format bridge (with
--v1-ledger/--v1-key, §12). Signed findings (withreviewrecords): areviewwithcitesmust be in a findings run, and its verdict must have a signed finding's shape: a kind, aTK-rule and a severity, and each closed field above of the values it allows (other fields pass), whether or not the run it analyses is in the bundle; when that run is, each cite must be a record of it with thatrun_seqandhash(reviewsfails otherwise); each finding is labelled by its author, the findings run'srun.registeredidentity:self-asserted(a warning) when that is the analysed run's identity or either run'ssigner_isolationissame-user; elseindependentwhen the trust config'sanalyzerslists it, elseunpinned author <id>. Without the analysed run, the findings areauthor not checkedand their cites not counted (reviews counted, a warning). Areviewwithoutcitesisimported. The signed findings themselves are summed up on aFindings:line afterIntegrityandAssurance, never in them: by severity, the coverage reviews, and every review's label (an imported review's severity is not counted). Live constraints: eachpolicy.decisionwhoserule_idsholdTK-CONSTRAINTis anaskafter aconstraint.addedin its run, elseconstraintsfails. -
Policy snapshots. Each
policies/file is named by its own SHA-256 (elsepolicy snapshotfails and neither check below reads it).tracekit export --v2bundles, from the signer's<data_dir>/policies, the compiled policy (canonical JSON, so its SHA-256 is itspolicy_hash) of eachpolicy_hasha verifier checks against, once: the one thepolicy.decisionan agent's approval answered cites (approvals by agentschecks its rules permit agents; without it, a warning) and the one policy a run with anincident.openedwas decided under. No other is bundled. Incidents (with containment records): a record an incident cites must, when its run is in the bundle, exist and be one that fires a trigger; an incident of scoperunciting only its own run is recomputed from that run's records under the bundled policy of the run's onepolicy_hash(not in the bundle: signer-asserted, with anincident policieswarning), and must follow from them; adetector_unavailableincident must cite a decision whosedetect.stateisunavailable; the rest are counted signer-asserted. Each response must have been honoured: while held, no allow and no ask without the incident id; while quarantined, only deny; a held call approved only by a person (neverapprover_kind: agent, never self-approved) with the incident id; no consume under a hold but of such an approval, none under a quarantine; notool.resultwhile held or quarantined but of a callin_flightthen or (held) consumed with such an approval (incidents). -
Ext (records with an
extfield or of typeext, §9). Each ext name the key and run records carry, in name order: the verifier's own check of it when it interprets it, else anot interpretedwarning counting the records that carry it, for the first 20 such names; one morenot interpretedwarning counts the others.amended(1.4.0):gateway amendments, passing withgateway amended n request(s) to show reasoning (<pointer>: <count>, ... the first 8 in code-point order); each record commits to the request as sent, a warning listing the records (the first 20) where it is anything but a non-empty list of JSON pointers of at most 256 characters on amodel.exchangerequest record of sourcegateway.openshell.policy(1.4.0):openshell policy, passing withOpenShell changed the sandbox policy or configuration n time(s) (<change>: <count>, ... the first 8 in code-point order), as OpenShell reported it, a warning listing the records (the first 20) that carry it other than as anextrecord of sourcesignerwith achangeof[a-z0-9_.-]{1,64}and a non-emptyobserver. -
Verdict. Any failed line:
FAILED, exit 1. OtherwiseVERIFIED(every check this verifier implements passed),VERIFIED TO HEAD n (open)for one open run, orVERIFIED (k run(s) open). Then the informational lines (policy, gaps, coverage, tiers, args source, isolation, fail-open classes, key assurance, break-glass approvals) and the assurance level. Exit 0, or 3 with--strictwhen any line is a warning (verdicts lists the gap kinds that warn).
A malformed bundle never crashes the verifier: anything unexpected is a failed bundle structure line.
tracekit signer bridge continues a v1 ledger in a v2 log without rewriting either (signer/format_bridge.py):
- The v1 ledger gets two last records, signed with the v1 key:
capture.gap{kind: format_upgrade}, then the key's retirement as a v1-validcapture.gap{kind: key_retire, reason: "key.retire kid=<v1 kid> last_seq=<its seq>"}. - The v2 log's first record is
signer.epoch{bridge: {v1_kid, v1_last_seq, v1_head}}, wherev1_headis the hash of step 1's last record. - The v1 signing key is overwritten and deleted.
tracekit verify bundle.tkb --trust trust.json --v1-ledger ledger.jsonl --v1-key signer.pub then checks that the v1
chain verifies by v1 rules up to v1_last_seq, that its last record is the retirement of v1_kid with hash v1_head,
and that no v1 record follows it. The frozen v1 verifier sees the two bridge records as ordinary signer gaps and can't
tell a later v1 record from a genuine one; only this check reports it.
| Implementation | Where | Notes |
|---|---|---|
| Python, the reference |
tracekit/verify/v2.py (tracekit verify) |
everything on this page |
| TypeScript, independent |
sdk/typescript/src/verify (@cygnuxlabs/tracekit/verify, npx @cygnuxlabs/tracekit verify) |
written from this page; Node ≥ 20.12 and browsers, Web Crypto only |
| TypeScript, in a browser |
verify.html (make verify-page; a release asset, in the npm package as dist/verify.html, hosted at https://cygnux-labs.github.io/Tracekit/verify/) |
the TypeScript verifier in one self-contained page: file inputs for the bundle and the trust config, and a Content Security Policy (default-src 'none', its one script and style by hash, connect-src 'none') that lets it load and send nothing |
The TypeScript verifier does not implement SLH-DSA (§6, Web Crypto has none), Rekor anchors (§8) or record-key
certificates (issuer.md), nor tracekit why findings (a bundle with a why.finding or a replay run): a
bundle that relies on one is UNVERIFIABLE (not checked by this verifier: ...), exit 2, never VERIFIED. It checks
signed findings (reviews, and the Findings: line) and live constraints (constraints) as the reference does. It
takes no monitor reports, revocations or v1 ledger. Both verifiers implement the non-critical slot and the upgrade
lines alike (§9, §11); the browser page reports exactly what the TypeScript CLI does. For evidence, check the page you
open against the release first: gh attestation verify verify.html --repo Cygnux-Labs/Tracekit
(releasing).
Agreement. tests/test_ts_verifier.py builds bundles with the real signer (honest runs: dev, witnessed, with a
gap, with an approval, a run-set; and mutated copies: edited, dropped and reordered records, swapped signatures, a
foreign key, a missing cosignature, a quorum not met, a forged inclusion proof, a bad manifest hash, a newer
verifier_min_version, a schema-invalid field) and takes the golden corpus (tests/golden/v2, tests/golden/negative),
runs both verifiers on each and requires the same integrity, assurance, exit code and check lines:
make test-ts # builds the TypeScript verifier; its suite runs the agreement tests too
python -m pytest tests/test_ts_verifier.py # skipped without Node >= 20.12A third implementation shows conformance the same way: reproduce every vector in tests/vectors (canonical JSON,
record hashes and signature messages, the Ed25519 acceptance rules, checkpoint notes and key ids) and the worked examples
on this page, then reach the reference's integrity, assurance and exit code on the differential corpus: the golden
bundles with their trust.json, and the bundles tests/test_ts_verifier.py builds.
Generated from docs/ at v1.4.1. Edit the docs in the repository, not here.
Get started
- Quickstart: the v2 signer
- Quickstarts
- Quickstart: Claude Agent SDK
- Quickstart: a custom agent
- Quickstart: LangChain and LangGraph
- Quickstart: MCP client
- Quickstart: OpenAI Agents SDK
- Coding agents other than Claude Code
- tracekit doctor
- Walkthrough: Tracekit end to end
How it works
- How Tracekit works
- Overview
- The life of a tool call
- The evidence format
- Verification and assurance
- Witnesses and monitoring
- Policy
- Approvals
- Detection and containment
- Capture and completeness
- Custody
- Provenance and tracekit why
- Deployment and isolation
- Storage, durability and performance
- Integrations
- Security model
- Architecture
- tracekit why: architecture
Integrations
- Integrations
- Adapters
- OpenTelemetry
- Remote clients
- Kubernetes agent-sandbox
- agentgateway
- agentsh and Tracekit
- Browser Use
- Claude Agent SDK
- Claude Code's sandbox
- Codex's sandbox
- CrewAI
- Docker MCP Gateway
- E2B
- Falco
- Gemini CLI's sandbox
- Google ADK
- Haystack
- LangChain v1
- LiteLLM
- LlamaIndex
- MCP client
- microsandbox
- Microsoft Agent Framework
- OpenAI Agents SDK
- NVIDIA OpenShell
- Pydantic AI
- sandbox-runtime's proxy on its own
- Strands Agents
- Tetragon
- Vercel AI SDK
- Wassette
Policy and approvals
- Policy reference
- Approvals
- Findings
- Reading the model's reasoning
- Incidents and containment
- Webhooks
Deploy and operate
- Deploying the signer
- The signer as a container
- The signer on Kubernetes
- The compose stack
- Identity
- Tool executor
- MCP gateway
- LLM gateway
- Egress observation
- Witnesses
- Log monitor
- Record-key issuer
- Signer observability
- Viewer
- tracekit why: causal investigation and replay
- Migrating
- Platforms
Verify the evidence
- Reading a v2 verify report
- Evidence format v2
- Auditor guide
- Sample evidence bundle
- Signing
- What Tracekit guarantees
Security and privacy
- Threat model: the v2 signer
- Threat model: the v1 laptop signer
- HTTP security checklist
- Threat-model review packet
- Privacy
- Known limits and trade-offs
- What's stable
Compliance
- Compliance mapping
- EU AI Act
- ISO/IEC 42001
- NIST AI RMF and the Generative AI Profile
- OWASP Top 10 lists for LLM and agentic applications
Evaluation
Demos
- Demos
- Agent approvers within bounds
- Completeness and tamper evidence
- Detection and containment
- Network coverage: an egress observer
- Guardrails: allow, deny, or ask a person
- MCP tools, gated and recorded
- Prompt injection held by provenance
- Why: which input caused the action
Help