Probity Catch-Record, v1
A catch-record is the forensic unit of evidence in the Probity Predicate Standard. It is the record emitted whenever an isolation or policy layer blocks (or would block) an exploit during an attack run. A run produces a set of catch-records; that set is the evidence behind every "this attack was blocked" claim in a security-verdict. Each record can be host-signed, and the ordered set of records for a run is committed under a single batch Merkle root so the set and order are tamper-evident.
A catch-record is security-intercept telemetry, not application or tool-call telemetry. It records what an isolation layer did, not what the workload was asked to do.
The key words MUST, MUST NOT, REQUIRED, SHOULD, MAY, and OPTIONAL are to be interpreted as in RFC 2119.
Purpose
When a verdict says an attack was blocked, a third party must be able to see the evidence that it was blocked and confirm that evidence was observed by the host, not merely asserted by the verdict. The catch-record carries that evidence: which layer fired, why, against what payload, at what site. Its per-record signature lets a verifier confirm the host observed that exact intercept; its batch root lets a verifier confirm no record was added, removed, or reordered. The verdict references the batch root rather than embedding the records, so the verdict stays bounded in size while the evidence stays cryptographically tied to it.
Record shape (JSON object)
A catch-record's signed pre-image is the commitments-only field set below. The offending
payload's raw bytes are NEVER part of it: the record commits a payload_commitment digest instead
(see Commitment, not raw payload). This is the exact field set whose
RFC 8785 canonical form is signed. A drained (signed) record is then carried as a DSSE envelope that
wraps these bytes (see Per-record signature).
| JSON key | Type | Required | Semantics |
|---|---|---|---|
timestamp | string (strict RFC3339 UTC, see below) | REQUIRED | Wall-clock time at the catch site. |
layer | string (registry enum) | REQUIRED | The layer that fired the catch. Value from the layer vocabulary in the vocabulary registry. |
file | string | REQUIRED | Source file of the emission site. |
line | integer | REQUIRED | Source line of the emission site (captured live). |
function | string | REQUIRED | Function at the emission site. |
violation_type | string (registry enum) | REQUIRED | The symbolic block reason. Value from the violation_type vocabulary in the vocabulary registry. |
payload_commitment | string (hex) | OPTIONAL | Hex digest committing the full offending payload. The algorithm is named by payload_commitment_alg; the raw payload bytes are never signed, only this commitment. |
payload_commitment_alg | string (enum) | OPTIONAL | How payload_commitment was computed: "sha256" (plain SHA-256 digest of the payload) or "hmac-sha256" (keyed digest). |
sealed_payload_ct_sha256 | string (64-hex) | OPTIONAL | SHA-256 of the age-sealed customer payload sidecar. Present only on a --recipient run; binds the signed record to the exact sealed ciphertext the customer decrypts. |
sink_blocked | string (registry enum) | OPTIONAL | The syscall / dial / exec primitive prevented. Value from the sink vocabulary in the vocabulary registry. |
guest_pid | integer | OPTIONAL | Isolation-level guest process id; absent (or 0) for host-side catches. |
bundle_id | string (64-hex) | OPTIONAL | Content-addressed SHA-256 of the verdict bundle (the run identity) this record belongs to; absent for a standalone record. |
dst_ip | string | OPTIONAL | Destination IP of the blocked dial / egress, when the catch is a network egress. |
dst_port | integer | OPTIONAL | Destination port of the blocked dial / egress. |
guest_src_ip | string | OPTIONAL | Source (guest) IP of the blocked egress, when known. |
guest_src_port | integer | OPTIONAL | Source (guest) port of the blocked egress, when known. |
was_tls | boolean | OPTIONAL | Whether the blocked connection was TLS. Omitted when false (Go omitempty parity). |
coverage | object | OPTIONAL | Run-level quarantine coverage counters (catches_seen, attributed, pauses_taken, dedup_suppressed, budget_suppressed, breaker_suppressed, pending_overflowed, pending_unserviced, not_bindable, breaker_opened, breaker_closed), all non-negative integers. Emitted on the periodic quarantine.coverage snapshot record so a crashed run's coverage is trustworthy up to its last signed flush. |
truncated | boolean | OPTIONAL | Whether the captured payload the commitment was computed over was truncated at the capture cap. Omitted when false (Go omitempty parity). |
catch_seq | integer (>= 1) | OPTIONAL | 1-based sequence of the triggering egress catch this record attributes; the self-contained join to that catch. Present only on a vmi.attribution record. |
guest_comm | string (<= 16 printable bytes) | OPTIONAL | The offending guest task's scrubbed TASK_COMM_LEN command name. Present only when task_labels is stable (omitted otherwise -- a proactive exec race can launder it). |
process_image_digest | string (64-hex) | OPTIONAL | Commitment to the offending task's executable image. Present only when task_labels is stable. |
enrichment_status | string (enum) | OPTIONAL | Disposition of the attribution attempt: resolved, timeout, unattributed, or degraded. Present only on a vmi.attribution record. |
enrichment_digest | string (64-hex) | OPTIONAL | JCS-SHA256 commitment over the enrichment sidecar (the ModelBOM + forensics), which is never embedded. Present only on a vmi.attribution record. |
task_labels | string (enum) | OPTIONAL | Trustworthiness of the offense-time labels: stable, exec_raced, or unverifiable. When not stable, guest_comm / process_image_digest are omitted rather than embedded wrong. Present only on a vmi.attribution record. |
catch_policy_digest | string (64-hex) | OPTIONAL | Policy-epoch commitment for a standalone vmi.attribution record; exactly one of catch_policy_digest / bundle_id is set. |
Field-presence rule: an absent OPTIONAL field MUST be omitted from the object entirely. It MUST NOT
be serialized as null. The boolean fields was_tls and truncated are omitted when false, never
serialized as false.
ModelBOM digest basis (cross-reference). The enrichment sidecar committed by enrichment_digest
(never embedded) carries the per-model ModelBOM. Its entries follow the ml-bom.md
identity contract: each entry's sha256 identity digest is the raw, uncompressed, full-file SHA-256
of the model weight file - NOT an OCI-layer, compressed-blob, or repository hash. That raw-file-SHA-256
basis is a stability contract: it lets a downstream in-toto training-provenance attestation keyed on
the same raw-file hash join to this catch's attribution evidence. See the digest-alignment stability
contract in ml-bom.md.
Unsigned wire-only fields
Two fields MAY ride the UNSIGNED wire for local operator triage but are NEVER part of the signed pre-image and are never cryptographically bound:
payload_snippet- a raw, bounded (<= 200 source bytes) hex preview of the offending payload. It is deliberately kept out of the signed surface so an immutable, third-party-verifiable log never carries raw sensitive bytes; only itspayload_commitmentis signed.timing_ms- a float wall-clock metric (protected-operation start to catch). A performance number, not catch evidence.
Because they are unsigned, a verifier MUST NOT treat either as evidence.
timestamp - strict fixed-precision RFC3339 UTC
The timestamp MUST be a strict, fixed-precision RFC 3339
UTC string with microsecond precision and a literal trailing Z. The exact form is:
YYYY-MM-DDTHH:MM:SS.ffffffZ
for example:
2026-06-15T12:00:00.000000Z
Conformance rules:
- The fractional-seconds field MUST be present and MUST be exactly six digits (microsecond precision).
- A producer MUST NOT trim trailing fractional zeros.
...000000Zis required;...0Zor...Zwith no fraction, or any precision other than six fractional digits, is non-conforming. - The zone designator MUST be the literal uppercase
Z(UTC). An explicit numeric offset (+00:00) or a lowercasezis non-conforming. - A verifier MUST reject any
timestampthat does not match this exact precision and format.
This is a deliberate tightening relative to a "treat the timestamp as verbatim opaque bytes" rule. Because the byte form of the timestamp is part of every signed pre-image, allowing variable fractional precision would make the same instant serialize to different bytes in different runtimes and break cross-language batch signatures. Fixing the precision makes the pre-image deterministic across producers. (Implementation note for producers built on a runtime that trims trailing fractional zeros - for example a tracer that emits trimmed RFC3339-nanosecond strings - conforming to this standard requires emitting fixed six-digit microsecond precision instead. That is a required producer change, not an optional one.)
Vocabulary fields
layer, violation_type, and sink_blocked draw their values from the closed enums in the
vocabulary registry (spec/v1/vocabulary.json is the machine SSOT — 15
layers, 28 violation_types, 7 sinks). A producer MUST map any internal naming onto the exact
registered strings before emitting a record. A verifier MUST reject a record whose layer,
violation_type, or present sink_blocked is not in the registry version it implements. The offline
reference verifiers enforce this fail-closed when the record surfaces in an
adversarial-execution-evidence bundle (rule 6, registry
membership).
Per-record signature (proof of intercept): the DSSE envelope
A host MAY sign each record so a third party can confirm the host observed that exact intercept rather than merely that a verdict asserts it. A drained (signed) catch-record is carried as a standard DSSE envelope that wraps the canonical pre-image:
{
"payload": "<base64std of the RFC 8785 (JCS) canonical pre-image bytes>",
"payloadType": "application/vnd.probity.catch-record.v2+json",
"signatures": [{ "keyid": "<64-hex>", "sig": "<base64 ed25519>" }]
}
payloadis the standard base64 of the EXACT signed bytes: the RFC 8785 canonical serialization of the commitments-only pre-image object. It is not a re-encoding of readable fields- it IS the signed bytes.
payloadTypeis fixed atapplication/vnd.probity.catch-record.v2+json, a dedicated type so a catch-record signature can never be replayed as a verdict-envelope signature.signatures[0].sigis the raw 64-byte ed25519 signature overPAE(payloadType, base64decode(payload)), base64-encoded.signatures[0].keyidis the stable id of the signing (intercept / trace) key. (See the note in signing-envelope.md regarding how it relates to the envelope-levelkeyid.)
An envelope MAY carry an OPTIONAL batchRoot sibling field (64-hex): the RFC 6962 batch STH
root over the run's ordered records (see Batch commitment). When present on any
record of a batch, every record in that batch MUST carry the same batchRoot value.
Reading a signed record is strictly verify-then-read (see
Verification procedure): a verifier checks the signature over the PAE of
base64decode(payload) FIRST, then json.loads(base64decode(payload)) on those same bytes to READ
the record's fields. There is NO camel<->snake reconstruction and NO step that rebuilds the pre-image
from a field whitelist - the payload field already IS the exact base64 of the signed canonical
bytes.
Canonical pre-image (frozen, cross-language)
The pre-image is the RFC 8785 (JSON Canonicalization Scheme) serialization of a JSON object over the FIXED commitments-only evidentiary field whitelist:
timestamp, layer, file, line, function, violation_type,
payload_commitment, payload_commitment_alg, sealed_payload_ct_sha256,
sink_blocked, guest_pid, bundle_id, dst_ip, dst_port, guest_src_ip,
guest_src_port, was_tls, coverage, truncated,
catch_seq, guest_comm, process_image_digest, enrichment_status,
enrichment_digest, task_labels, catch_policy_digest
The final seven (catch_seq .. catch_policy_digest) ride only a vmi.attribution
(block-without-kill) record; coverage rides only a quarantine.coverage snapshot.
RFC 8785 sorts keys at serialization time, so this order is documentation only. Only the fields
actually present on the record appear (an absent optional field is omitted, never serialized as
null; a false boolean is omitted, never false). The object MUST conform to the following profile,
which every producer and verifier MUST enforce (reject, never coerce):
- Values are strings, integers, and booleans only. No floats, no
null. The only boolean fields arewas_tlsandtruncated. - Integer fields (
line,guest_pid,dst_port,guest_src_port,catch_seq, and the nestedcoverage.*counters) stay within the IEEE-754 exact range (+/-(2^53 - 1)) so RFC 8785 number serialization is plain decimal in every language. They are all bounded far below that limit. - Key names are ASCII, so RFC 8785's UTF-16 code-unit key ordering coincides with bytewise ordering.
- String values follow RFC 8785 escaping exactly: only control characters (U+0000-U+001F) are escaped; all other characters, including non-ASCII and U+2028 / U+2029, are emitted as raw UTF-8. (Some stock JSON encoders unconditionally escape U+2028 / U+2029 and are not RFC 8785 conformant; use an RFC 8785 implementation.)
timestampis included verbatim as the strict fixed-precision string defined above. Because precision is fixed at six microsecond digits, the same instant serializes to the same bytes in every conforming runtime, so cross-language signatures agree.
Deliberately EXCLUDED from the whitelist:
- The DSSE envelope's own
signatures/keyid- a signature cannot cover itself. payload_snippet- the raw payload preview. The signed surface never carries raw sensitive bytes; onlypayload_commitment(withpayload_commitment_algnaming how it was computed) is signed.timing_ms- a float performance metric, not catch evidence. The signature commits the catch identity (layer, violation type, payload commitment, source site, timestamp), not timing.
Commitment, not raw payload
The signed pre-image never contains the offending payload's raw bytes. Instead it commits:
payload_commitment- a hex digest of the full offending payload, withpayload_commitment_algnaming the algorithm ("sha256"= plain digest,"hmac-sha256"= keyed digest); and- OPTIONAL
sealed_payload_ct_sha256- on a--recipientrun, the SHA-256 of the age-sealed customer payload sidecar, binding the signed record to the exact ciphertext the customer decrypts.
The raw preview (payload_snippet) stays on the unsigned wire only, so an immutable,
third-party-verifiable log never carries raw sensitive bytes.
Signing context (domain separation)
The signature is computed over the PAE of the canonical pre-image bytes under the catch-record payload type:
PAE("application/vnd.probity.catch-record.v2+json", base64decode(payload))
The signature is the raw 64-byte ed25519 signature, base64-encoded into signatures[0].sig. A
verifier MUST reconstruct the PAE with this exact payload type and reject on mismatch.
Batch commitment
A signed run also emits a batch root binding the SET and ORDER of its records, surfaced as the
OPTIONAL batchRoot sibling on each record's DSSE envelope. The root is an
RFC 6962-style Merkle tree over the ordered
per-record signed pre-image bytes (the base64decode(payload) of each record envelope, in run
order):
- leaf hash =
SHA-256( 0x00 || base64decode(payload) ) - interior hash =
SHA-256( 0x01 || left || right ) - an unpaired (odd) node is promoted unchanged to the next level.
The batch root signature is over the PAE of the batch-root body under the batch-root payload type:
body = uint64-BE(count) || root[32]
PAE("application/vnd.probity.catch-batch-root.v1", body)
Binding the record count inside the signed body binds the tree shape, so a verifier can confirm no
record was added or removed. The RFC 6962 tree structure also admits per-record inclusion proofs: a
single catch can later be disclosed (with its inclusion proof) without revealing the rest of the
batch.
bundle_id and content addressing
bundle_id is the content-addressed SHA-256 of the verdict bundle a record belongs to. It ties a
record to its bundle by content (not by a mutable name) and is the link a
security-verdict uses to reference its evidence: the verdict carries the batch
root and the bundle_id, and a verifier resolves the records from content-addressed storage and
confirms they hash to that batch root under that bundle_id.
Verification procedure
Given a catch record (each a DSSE envelope) or a batch of them, and the pinned trace public key:
- Verify the signature first. For each record envelope, confirm
payloadTypeisapplication/vnd.probity.catch-record.v2+json, then checked25519.verify(pinned_pub, base64decode(signatures[0].sig), PAE(payloadType, base64decode(payload)))against the pinned trace key. Reject on failure. Thepayloadfield IS the exact signed bytes - there is no reconstruction step. - Then read. Decode the verified bytes with
json.loads(base64decode(payload))to obtain the record's fields. Enforce per-record CANON-EQ: the decoded record MUST re-canonicalize (RFC 8785 JCS) to the exactbase64decode(payload)bytes; reject if it does not. This guarantees no two parsers can read the same signed bytes differently. - Validate the shape of the decoded record: required fields present, optional fields either present
and well-typed or omitted (never
null; a false boolean omitted, neverfalse), andtimestampmatching the strict fixed-precision RFC3339 UTC form exactly. Reject on any violation. - Validate
layer,violation_type, and any presentsink_blockedagainst the vocabulary registry version implemented. Reject any unregistered value. - For a batch, recompute the RFC 6962 Merkle root over the ordered per-record signed bytes (the
base64decode(payload)of each envelope, in run order) and compare it to thebatchRootsibling when present. Reject on mismatch. (A run MAY additionally sign the batch-root body under the batch-root payload type; verify that signature as in Batch commitment when it is carried.)
No live execution environment is required.
What a verifier can and cannot independently check (honesty)
- A verifier CAN confirm the host signed each record and that the set and order are intact (the signatures and the batch root).
- A verifier CANNOT, from the record alone, confirm the recorded
file/line/functionemission site is the real emission site in the producer's code, nor that thepayload_snippet/payload_commitmentcorresponds to a genuinely malicious payload. These are host self-assertions carried under the host signature: the verifier learns the host committed to them, not that they are independently true. The catch-record's guarantee is integrity and provenance of the host's claim, not external corroboration of the claim's content.
Related standards
- RFC 8785 (JCS) - the canonical pre-image serialization.
- RFC 6962 - the Merkle batch-commitment tree shape and inclusion proofs.
- DSSE - the PAE domain-separation construction (see signing-envelope.md).
- OpenTelemetry GenAI semantic conventions - INTENTIONALLY NOT used. A catch-record is VMM / policy security-intercept telemetry; the OpenTelemetry GenAI tool-call conventions have no slot for an isolation-layer intercept, a guest process id, or a DNS-pin miss. Application-level trace correlation, if any, lives at the verdict's tool-call envelope, not in the catch-record.
Versioning
This is the catch-record v2 wire shape: the pre-image schema and the DSSE payloadType
(application/vnd.probity.catch-record.v2+json) are both v2. The v2 field set is
commitments-only - payload_commitment + payload_commitment_alg replacing the former
full_payload_sha256, sealed_payload_ct_sha256 added, and signing carried through the DSSE
envelope rather than inline signature / key_id fields. That change reshapes the canonical
pre-image field set, which is exactly the class this section reserves for a wire-version bump, hence
v2. The canonicalization SCHEME (the RFC 8785 JCS profile over the whitelisted fields) is unchanged
from v1 and is versioned independently of the field set. These records are carried as the
observationRecords of the adversarial-execution-evidence v0.7
predicate (https://in-toto.io/attestation/adversarial-execution-evidence/v0.7). Adding a new
optional field is additive and stays within v2. Any rename, type change, or removal of an existing
field, any change to the canonical pre-image whitelist or its serialization profile, or any change
to the timestamp precision rule is a breaking change and requires catch-record v3. Vocabulary values
follow the vocabulary registry's own versioning.