# 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](security-verdict.md). 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](https://www.rfc-editor.org/rfc/rfc2119).

## 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](#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](#per-record-signature-proof-of-intercept-the-dsse-envelope)).

| 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](vocabulary-registry.md). |
| `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](vocabulary-registry.md). |
| `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](vocabulary-registry.md). |
| `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](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](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 its `payload_commitment` is 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](https://www.rfc-editor.org/rfc/rfc3339)
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. `...000000Z` is required; `...0Z` or `...Z`
  with 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 lowercase `z` is non-conforming.
- A verifier MUST reject any `timestamp` that 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](vocabulary-registry.md) (`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](adversarial-execution-evidence.md) 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:

```json
{
  "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>" }]
}
```

- **`payload`** is 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.
- **`payloadType`** is fixed at `application/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].sig`** is the raw 64-byte ed25519 signature over
  `PAE(payloadType, base64decode(payload))`, base64-encoded.
- **`signatures[0].keyid`** is the stable id of the signing (intercept / trace) key. (See the note
  in [signing-envelope.md](signing-envelope.md) regarding how it relates to the envelope-level
  `keyid`.)

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](#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](#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)](https://www.rfc-editor.org/rfc/rfc8785)
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
  are `was_tls` and `truncated`.
- Integer fields (`line`, `guest_pid`, `dst_port`, `guest_src_port`, `catch_seq`, and the nested
  `coverage.*` 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.)
- `timestamp` is 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;
  only `payload_commitment` (with `payload_commitment_alg` naming 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, with `payload_commitment_alg`
  naming the algorithm (`"sha256"` = plain digest, `"hmac-sha256"` = keyed digest); and
- OPTIONAL `sealed_payload_ct_sha256` - on a `--recipient` run, 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](https://www.rfc-editor.org/rfc/rfc6962#section-2.1) 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](security-verdict.md) 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:

1. **Verify the signature first.** For each record envelope, confirm `payloadType` is
   `application/vnd.probity.catch-record.v2+json`, then check
   `ed25519.verify(pinned_pub, base64decode(signatures[0].sig), PAE(payloadType, base64decode(payload)))`
   against the pinned trace key. Reject on failure. The `payload` field IS the exact signed bytes -
   there is no reconstruction step.
2. **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 exact `base64decode(payload)` bytes; reject if it does not. This guarantees no two
   parsers can read the same signed bytes differently.
3. 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, never `false`), and `timestamp`
   matching the strict fixed-precision RFC3339 UTC form exactly. Reject on any violation.
4. Validate `layer`, `violation_type`, and any present `sink_blocked` against the
   [vocabulary registry](vocabulary-registry.md) version implemented. Reject any unregistered value.
5. 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 the `batchRoot` sibling
   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](#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` / `function`
  emission site is the *real* emission site in the producer's code, nor that the `payload_snippet` /
  `payload_commitment` corresponds 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)](https://www.rfc-editor.org/rfc/rfc8785)** - the canonical pre-image
  serialization.
- **[RFC 6962](https://www.rfc-editor.org/rfc/rfc6962)** - the Merkle batch-commitment tree shape and
  inclusion proofs.
- **[DSSE](https://github.com/secure-systems-lab/dsse)** - the PAE domain-separation construction (see
  [signing-envelope.md](signing-envelope.md)).
- **[OpenTelemetry](https://opentelemetry.io/) 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](adversarial-execution-evidence.md)
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](vocabulary-registry.md)'s own versioning.
